Revamped the statistical page to work with the new entity system, along with some other improvements to it

This commit is contained in:
Tiago Ribeiro
2024-11-21 15:37:53 +00:00
parent 0eed8e4612
commit f301001ebe
8 changed files with 1052 additions and 541 deletions

View File

@@ -6,102 +6,103 @@ import { BsArrowDown, BsArrowUp } from "react-icons/bs"
import Button from "../Low/Button" import Button from "../Low/Button"
interface Props<T> { interface Props<T> {
data: T[] data: T[]
columns: ColumnDef<any, any>[] columns: ColumnDef<any, any>[]
searchFields: string[][] searchFields: string[][]
size?: number size?: number
onDownload?: (rows: T[]) => void onDownload?: (rows: T[]) => void
searchPlaceholder?: string
} }
export default function Table<T>({ data, columns, searchFields, size = 16, onDownload }: Props<T>) { export default function Table<T>({ data, columns, searchFields, size = 16, onDownload, searchPlaceholder }: Props<T>) {
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 16, pageSize: size,
}) })
const { rows, renderSearch } = useListSearch<T>(searchFields, data); const { rows, renderSearch } = useListSearch<T>(searchFields, data, searchPlaceholder);
const table = useReactTable({ const table = useReactTable({
data: rows, data: rows,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination, onPaginationChange: setPagination,
state: { state: {
pagination pagination
} }
}); });
return ( return (
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="w-full flex gap-2 items-end"> <div className="w-full flex gap-2 items-end">
{renderSearch()} {renderSearch()}
{onDownload && ( {onDownload && (
<Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}> <Button className="w-full max-w-[200px] mb-1" variant="outline" onClick={() => onDownload(rows)}>
Download List Download
</Button> </Button>
) )
} }
</div> </div>
<div className="w-full flex gap-2 justify-between items-center"> <div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}> <Button className="w-[200px] h-fit" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()}>
Previous Page Previous Page
</Button> </Button>
</div> </div>
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<div>Page</div> <div>Page</div>
<strong> <strong>
{table.getState().pagination.pageIndex + 1} of{' '} {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount().toLocaleString()} {table.getPageCount().toLocaleString()}
</strong> </strong>
<div>| Total: {table.getRowCount().toLocaleString()}</div> <div>| Total: {table.getRowCount().toLocaleString()}</div>
</span> </span>
<Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}> <Button className="w-[200px]" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
Next Page Next Page
</Button> </Button>
</div> </div>
</div> </div>
<table className="rounded-xl bg-mti-purple-ultralight/40 w-full"> <table className="rounded-xl bg-mti-purple-ultralight/40 w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}> <th className="py-4 px-4 text-left" key={header.id} colSpan={header.colSpan}>
<div <div
className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')} className={clsx(header.column.getCanSort() && 'cursor-pointer select-none', 'flex items-center gap-2')}
onClick={header.column.getToggleSortingHandler()} onClick={header.column.getToggleSortingHandler()}
> >
{flexRender( {flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext() header.getContext()
)} )}
{{ {{
asc: <BsArrowUp />, asc: <BsArrowUp />,
desc: <BsArrowDown />, desc: <BsArrowDown />,
}[header.column.getIsSorted() as string] ?? null} }[header.column.getIsSorted() as string] ?? null}
</div> </div>
</th> </th>
))} ))}
</tr> </tr>
))} ))}
</thead> </thead>
<tbody className="px-2 w-full"> <tbody className="px-2 w-full">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2 items-center w-fit" key={cell.id}> <td className="px-4 py-2 items-center w-fit" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>
))} ))}
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
) )
} }

View File

@@ -1,11 +1,12 @@
import {useState, useMemo} from "react"; import { useState, useMemo } from "react";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import {search} from "@/utils/search"; import { search } from "@/utils/search";
export function useListSearch<T>(fields: string[][], rows: T[]) { export function useListSearch<T>(fields: string[][], rows: T[], placeholder?: string) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const renderSearch = () => <Input type="text" name="search" onChange={setText} placeholder="Enter search text" value={text} />; const renderSearch = () =>
<Input type="text" name="search" onChange={setText} placeholder={placeholder || "Enter search text"} value={text} />;
const updatedRows = useMemo(() => { const updatedRows = useMemo(() => {
if (text.length > 0) return search(text, fields, rows); if (text.length > 0) return search(text, fields, rows);

View File

@@ -0,0 +1,191 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { getDownloadURL, getStorage, ref } from "firebase/storage";
import { app, storage } from "@/firebase";
import axios from "axios";
import { requestUser } from "@/utils/api";
import { checkAccess } from "@/utils/permissions";
import { EntityWithRoles } from "@/interfaces/entity";
import { Stat, StudentUser } from "@/interfaces/user";
import { Assignment, AssignmentResult } from "@/interfaces/results";
import { Exam } from "@/interfaces/exam";
import { capitalize, groupBy, uniqBy } from "lodash";
import { findBy, mapBy } from "@/utils";
import ExcelJS from "exceljs";
import moment from "moment";
import { Session } from "@/hooks/useSessions";
export default withIronSessionApiRoute(handler, sessionOptions);
interface Item {
student: StudentUser
result: AssignmentResult
assignment: Assignment
exams: Exam[]
session?: Session
}
interface Body {
entities: EntityWithRoles[]
items: Item[]
assignments: Assignment[]
startDate: Date
endDate: Date
}
interface EntityInformation {
entity: EntityWithRoles
exams: Exam[]
numberOfAssignees: number
numberOfSubmissions: number
numberOfAbsentees: number
assignment: Assignment
items: Item[]
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(404).json({ ok: false })
const user = await requestUser(req, res)
if (!user) return res.status(401).json({ ok: false });
if (!checkAccess(user, ['admin', 'developer', 'mastercorporate', 'corporate'])) return res.status(403).json({ ok: false });
const { entities, items, assignments } = req.body as Body
const entityInformations: EntityInformation[] = []
for (const entity of entities) {
const entityItems = items.filter(i => i.assignment.entity === entity.id)
const groupedByAssignments = groupBy(entityItems, (a) => a.assignment.id)
for (const assignmentID of Object.keys(groupedByAssignments)) {
const assignmentItems = groupedByAssignments[assignmentID]
const assignment = findBy(assignments, 'id', assignmentID)!
const assignmentExams =
uniqBy(assignmentItems.flatMap(a => a.exams.map(e => ({ ...e, moduleID: `${e.id}_${e.module}` }))), 'moduleID')
const assignmentEntityInformation: EntityInformation = {
entity,
exams: assignmentExams,
numberOfAssignees: assignmentItems.length,
numberOfSubmissions: assignmentItems.filter(x => !!x.result).length,
numberOfAbsentees: assignmentItems.filter(x => !x.result).length,
assignment,
items: assignmentItems
}
entityInformations.push(assignmentEntityInformation)
}
}
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Statistical");
entityInformations.forEach((e) => addEntityInformationToWorksheet(worksheet, e))
const buffer = await workbook.xlsx.writeBuffer()
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.status(200).send(buffer);
}
const addEntityInformationToWorksheet = (worksheet: ExcelJS.Worksheet, entityInformation: EntityInformation) => {
const data = [
['Entity', undefined, undefined, entityInformation.entity.label],
['Assignment', undefined, undefined, entityInformation.assignment.name],
['Date of the Assignment', undefined, undefined, moment(entityInformation.assignment.startDate).format("DD/MM/YYYY")],
['Exams', undefined, undefined, mapBy(entityInformation.exams, 'id').join(', ')],
['Modules', undefined, undefined, entityInformation.exams.map(e => capitalize(e.module)).join(', ')],
['Number of Assignees', undefined, undefined, entityInformation.numberOfAssignees],
['Number of Submissions', undefined, undefined, entityInformation.numberOfSubmissions],
['Number of Absentees', undefined, undefined, entityInformation.numberOfAbsentees]
]
const dataRows = worksheet.addRows(data);
dataRows.forEach(row => row.getCell(1).font = { bold: true, color: { argb: "ffffffff" } })
dataRows.forEach(row => row.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: "ff674ea7" } })
dataRows.forEach(row => worksheet.mergeCells(`${row.getCell(1).address}:${row.getCell(3).address}`))
dataRows.forEach(row => row.getCell(4).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: "FFD9D2E9" } })
dataRows.forEach(row => worksheet.mergeCells(`${row.getCell(4).address}:${row.getCell(7).address}`))
worksheet.addRows([[], []]);
for (const exam of entityInformation.exams) {
const examRow = worksheet.addRow([`${capitalize(exam.module)} Exam`, undefined, exam.id])
examRow.getCell(1).font = { bold: true, color: { argb: "ffffffff" } }
examRow.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: "ff674ea7" } }
examRow.getCell(3).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: "FFD9D2E9" } }
worksheet.mergeCells(`${examRow.getCell(1).address}:${examRow.getCell(2).address}`)
worksheet.mergeCells(`${examRow.getCell(3).address}:${examRow.getCell(6).address}`)
const parts = exam.module === "level" || exam.module === "listening" || exam.module === "reading" ? exam.parts : []
const header = worksheet.addRow([
"#",
"Name",
"E-mail",
"Student ID",
"Passport/ID",
"Gender",
"Score",
...parts.map((_, i) => `Part ${i + 1}`)
])
header.font = { bold: true, color: { argb: "FFFFFFFF" } }
header.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: "FFD9D2E9" } }
const examItems =
entityInformation.items
.filter(i => !!i.result)
.map(i => ({
...i,
result: { ...i.result, stats: i.result.stats.filter(x => x.exam === exam.id) },
}))
const orderedItems = examItems.sort((a, b) => {
const aTotalScore = a.result.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0)
const bTotalScore = b.result.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0)
return bTotalScore - aTotalScore
})
const itemRows = orderedItems.map((item, index) => {
const { total, correct } = calculateScore(item.result.stats)
const score = `${correct} / ${total}`
return [
index + 1,
item.student.name,
item.student.email,
item.student.studentID || "N/A",
item.student.demographicInformation?.passport_id || "N/A",
item.student.demographicInformation?.gender || "N/A",
score,
...parts.map((part) => {
const exerciseIDs = mapBy(part.exercises, 'id')
const { total, correct } = calculateScore(item.result.stats.filter(s => exerciseIDs.includes(s.exercise)))
return `${correct} / ${total}`
})
]
})
worksheet.addRows(itemRows)
worksheet.addRows([[]]);
}
worksheet.addRows([[], []]);
}
const calculateScore = (stats: Stat[]) => {
const total = stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.total, 0)
const correct = stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0)
return { total, correct }
}
export const config = {
api: {
bodyParser: {
sizeLimit: '20mb',
},
},
};

View File

@@ -25,174 +25,179 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data, BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPaperclip, BsPaperclip,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
BsPeopleFill, BsPeopleFill,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
stats: Stat[]; stats: Stat[];
groups: Group[]; groups: Group[];
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
const users = await getUsers(); const users = await getUsers();
const entities = await getEntitiesWithRoles(); const entities = await getEntitiesWithRoles();
const assignments = await getAssignments(); const assignments = await getAssignments();
const stats = await getStatsByUsers(users.map((u) => u.id)); const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroups(); const groups = await getGroups();
return { props: serialize({ user, users, entities, assignments, stats, groups }) }; return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
const router = useRouter(); const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus, focus: students.find((u) => u.id === s.user)?.focus,
score: s.score, score: s.score,
module: s.module, module: s.module,
})) }))
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
})); }));
const levels: { [key in Module]: number } = { const levels: { [key in Module]: number } = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
level: 0, level: 0,
}; };
bandScores.forEach((b) => (levels[b.module] += b.level)); bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
}; };
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={masterCorporates.length}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length} value={students.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsEnvelopePaper} onClick={() => router.push("/users?type=teacher")}
onClick={() => router.push("/assignments")} Icon={BsPencilSquare}
label="Assignments" label="Teachers"
value={assignments.filter((a) => !a.archived).length} value={teachers.length}
color="purple" color="purple"
/> />
</section> <IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={masterCorporates.length}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers" title="Latest Teachers"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList
users={ users={
students students
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length,
) )
} }
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </Layout>
</> </>
); );
} }

View File

@@ -25,174 +25,179 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data, BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPaperclip, BsPaperclip,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
BsPeopleFill, BsPeopleFill,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
stats: Stat[]; stats: Stat[];
groups: Group[]; groups: Group[];
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer"])) return redirect("/") if (!checkAccess(user, ["admin", "developer"])) return redirect("/")
const users = await getUsers(); const users = await getUsers();
const entities = await getEntitiesWithRoles(); const entities = await getEntitiesWithRoles();
const assignments = await getAssignments(); const assignments = await getAssignments();
const stats = await getStatsByUsers(users.map((u) => u.id)); const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroups(); const groups = await getGroups();
return { props: serialize({ user, users, entities, assignments, stats, groups }) }; return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]);
const router = useRouter(); const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus, focus: students.find((u) => u.id === s.user)?.focus,
score: s.score, score: s.score,
module: s.module, module: s.module,
})) }))
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
})); }));
const levels: { [key in Module]: number } = { const levels: { [key in Module]: number } = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
level: 0, level: 0,
}; };
bandScores.forEach((b) => (levels[b.module] += b.level)); bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
}; };
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={masterCorporates.length}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length} value={students.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsEnvelopePaper} onClick={() => router.push("/users?type=teacher")}
onClick={() => router.push("/assignments")} Icon={BsPencilSquare}
label="Assignments" label="Teachers"
value={assignments.filter((a) => !a.archived).length} value={teachers.length}
color="purple" color="purple"
/> />
</section> <IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=corporate")}
label="Corporates"
value={corporates.length}
color="purple"
/>
<IconCard
Icon={BsBank}
onClick={() => router.push("/users?type=mastercorporate")}
label="Master Corporates"
value={masterCorporates.length}
color="purple"
/>
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers" title="Latest Teachers"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList
users={ users={
students students
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length,
) )
} }
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </Layout>
</> </>
); );
} }

View File

@@ -26,180 +26,185 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
BsBank, BsBank,
BsClipboard2Data, BsClipboard2Data,
BsClock, BsClock,
BsEnvelopePaper, BsEnvelopePaper,
BsPaperclip, BsPaperclip,
BsPencilSquare, BsPencilSquare,
BsPeople, BsPeople,
BsPeopleFill, BsPeopleFill,
BsPersonFill, BsPersonFill,
BsPersonFillGear, BsPersonFillGear,
} from "react-icons/bs"; } from "react-icons/bs";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
interface Props { interface Props {
user: User; user: User;
users: User[]; users: User[];
entities: EntityWithRoles[]; entities: EntityWithRoles[];
assignments: Assignment[]; assignments: Assignment[];
stats: Stat[]; stats: Stat[];
groups: Group[]; groups: Group[];
} }
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res) const user = await requestUser(req, res)
if (!user) return redirect("/login") if (!user) return redirect("/login")
if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
return redirect("/") return redirect("/")
const entityIDS = mapBy(user.entities, "id") || []; const entityIDS = mapBy(user.entities, "id") || [];
const entities = await getEntitiesWithRoles(entityIDS); const entities = await getEntitiesWithRoles(entityIDS);
const users = await filterAllowedUsers(user, entities) const users = await filterAllowedUsers(user, entities)
const assignments = await getEntitiesAssignments(entityIDS); const assignments = await getEntitiesAssignments(entityIDS);
const stats = await getStatsByUsers(users.map((u) => u.id)); const stats = await getStatsByUsers(users.map((u) => u.id));
const groups = await getGroupsByEntities(entityIDS); const groups = await getGroupsByEntities(entityIDS);
return { props: serialize({ user, users, entities, assignments, stats, groups }) }; return { props: serialize({ user, users, entities, assignments, stats, groups }) };
}, sessionOptions); }, sessionOptions);
export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) {
const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); const students = useMemo(() => users.filter((u) => u.type === "student"), [users]);
const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]);
const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]);
const router = useRouter(); const router = useRouter();
const averageLevelCalculator = (studentStats: Stat[]) => { const averageLevelCalculator = (studentStats: Stat[]) => {
const formattedStats = studentStats const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: students.find((u) => u.id === s.user)?.focus, focus: students.find((u) => u.id === s.user)?.focus,
score: s.score, score: s.score,
module: s.module, module: s.module,
})) }))
.filter((f) => !!f.focus); .filter((f) => !!f.focus);
const bandScores = formattedStats.map((s) => ({ const bandScores = formattedStats.map((s) => ({
module: s.module, module: s.module,
level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!),
})); }));
const levels: { [key in Module]: number } = { const levels: { [key in Module]: number } = {
reading: 0, reading: 0,
listening: 0, listening: 0,
writing: 0, writing: 0,
speaking: 0, speaking: 0,
level: 0, level: 0,
}; };
bandScores.forEach((b) => (levels[b.module] += b.level)); bandScores.forEach((b) => (levels[b.module] += b.level));
return calculateAverageLevel(levels); return calculateAverageLevel(levels);
}; };
const UserDisplay = (displayUser: User) => ( const UserDisplay = (displayUser: User) => (
<div className="flex w-full p-4 gap-4 items-center hover:bg-mti-purple-ultralight cursor-pointer transition ease-in-out duration-300"> <div 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" /> <img src={displayUser.profilePicture} alt={displayUser.name} className="rounded-full w-10 h-10" />
<div className="flex flex-col gap-1 items-start"> <div className="flex flex-col gap-1 items-start">
<span>{displayUser.name}</span> <span>{displayUser.name}</span>
<span className="text-sm opacity-75">{displayUser.email}</span> <span className="text-sm opacity-75">{displayUser.email}</span>
</div> </div>
</div> </div>
); );
return ( return (
<> <>
<Head> <Head>
<title>EnCoach</title> <title>EnCoach</title>
<meta <meta
name="description" name="description"
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop." 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" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<Layout user={user}> <Layout user={user}>
<section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center"> <section className="grid grid-cols-5 place-items-center -md:grid-cols-2 gap-4 text-center">
<IconCard <IconCard
onClick={() => router.push("/users?type=student")} onClick={() => router.push("/users?type=student")}
Icon={BsPersonFill} Icon={BsPersonFill}
label="Students" label="Students"
value={students.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=teacher")}
Icon={BsPencilSquare}
label="Teachers"
value={teachers.length}
color="purple"
/>
<IconCard
onClick={() => router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" />
<IconCard
Icon={BsPeople}
onClick={() => router.push("/classrooms")}
label="Classrooms"
value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPaperclip} label="Average Level" value={averageLevelCalculator(stats).toFixed(1)} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length} value={students.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsEnvelopePaper} onClick={() => router.push("/users?type=teacher")}
onClick={() => router.push("/assignments")} Icon={BsPencilSquare}
label="Assignments" label="Teachers"
value={assignments.filter((a) => !a.archived).length} value={teachers.length}
color="purple" color="purple"
/> />
<IconCard <IconCard
Icon={BsClock} onClick={() => router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" />
label="Expiration Date" <IconCard
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"} Icon={BsPeople}
color="rose" onClick={() => router.push("/classrooms")}
/> label="Classrooms"
</section> value={groups.length}
color="purple"
/>
<IconCard Icon={BsPeopleFill}
onClick={() => router.push("/entities")}
label="Entities"
value={entities.length}
color="purple"
/>
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").length} color="purple" />
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/users/performance")}
label="Student Performance"
value={students.length}
color="purple"
/>
<IconCard Icon={BsPersonFillGear}
onClick={() => router.push("/statistical")}
label="Entity Statistics"
value={entities.length}
color="purple"
/>
<IconCard
Icon={BsEnvelopePaper}
onClick={() => router.push("/assignments")}
label="Assignments"
value={assignments.filter((a) => !a.archived).length}
color="purple"
/>
<IconCard
Icon={BsClock}
label="Expiration Date"
value={user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/yyyy") : "Unlimited"}
color="rose"
/>
</section>
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between"> <section className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-between">
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={students.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Students" title="Latest Students"
/> />
<UserDisplayList <UserDisplayList
users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))} users={teachers.sort((a, b) => dateSorter(a, b, "desc", "registrationDate"))}
title="Latest Teachers" title="Latest Teachers"
/> />
<UserDisplayList <UserDisplayList
users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} users={students.sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))}
title="Highest level students" title="Highest level students"
/> />
<UserDisplayList <UserDisplayList
users={ users={
students students
.sort( .sort(
(a, b) => (a, b) =>
Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", b))).length -
Object.keys(groupByExam(filterBy(stats, "user", a))).length, Object.keys(groupByExam(filterBy(stats, "user", a))).length,
) )
} }
title="Highest exam count students" title="Highest exam count students"
/> />
</section> </section>
</Layout> </Layout>
</> </>
); );
} }

298
src/pages/statistical.tsx Normal file
View 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>
</>
)
}

View File

@@ -1,4 +1,4 @@
import {Session} from "@/hooks/useSessions"; import { Session } from "@/hooks/useSessions";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -6,11 +6,16 @@ const db = client.db(process.env.MONGODB_DB);
export const getSessionsByUser = async (id: string, limit = 0, filter = {}) => export const getSessionsByUser = async (id: string, limit = 0, filter = {}) =>
await db await db
.collection("sessions") .collection("sessions")
.find<Session>({user: id, ...filter}) .find<Session>({ user: id, ...filter })
.limit(limit || 0) .limit(limit || 0)
.toArray(); .toArray();
export const getSessionByAssignment = async (assignmentID: string) => export const getSessionByAssignment = async (assignmentID: string) =>
await db await db
.collection("sessions") .collection("sessions")
.findOne<Session>({"assignment.id": assignmentID}) .findOne<Session>({ "assignment.id": assignmentID })
export const getSessionsByAssignments = async (assignmentIDs: string[]) =>
await db
.collection("sessions")
.find<Session>({ "assignment.id": { $in: assignmentIDs } }).toArray()