From f301001ebe2848392cbcee4450113cb412fa9107 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 21 Nov 2024 15:37:53 +0000 Subject: [PATCH] Revamped the statistical page to work with the new entity system, along with some other improvements to it --- src/components/High/Table.tsx | 183 +++++++------- src/hooks/useListSearch.tsx | 9 +- src/pages/api/statistical.ts | 191 +++++++++++++++ src/pages/dashboard/admin.tsx | 297 +++++++++++------------ src/pages/dashboard/developer.tsx | 297 +++++++++++------------ src/pages/dashboard/mastercorporate.tsx | 305 ++++++++++++------------ src/pages/statistical.tsx | 298 +++++++++++++++++++++++ src/utils/sessions.be.ts | 13 +- 8 files changed, 1052 insertions(+), 541 deletions(-) create mode 100644 src/pages/api/statistical.ts create mode 100644 src/pages/statistical.tsx diff --git a/src/components/High/Table.tsx b/src/components/High/Table.tsx index c5b01e78..4ca9f780 100644 --- a/src/components/High/Table.tsx +++ b/src/components/High/Table.tsx @@ -6,102 +6,103 @@ import { BsArrowDown, BsArrowUp } from "react-icons/bs" import Button from "../Low/Button" interface Props { - data: T[] - columns: ColumnDef[] - searchFields: string[][] - size?: number - onDownload?: (rows: T[]) => void + data: T[] + columns: ColumnDef[] + searchFields: string[][] + size?: number + onDownload?: (rows: T[]) => void + searchPlaceholder?: string } -export default function Table({ data, columns, searchFields, size = 16, onDownload }: Props) { - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 16, - }) +export default function Table({ data, columns, searchFields, size = 16, onDownload, searchPlaceholder }: Props) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: size, + }) - const { rows, renderSearch } = useListSearch(searchFields, data); + const { rows, renderSearch } = useListSearch(searchFields, data, searchPlaceholder); - const table = useReactTable({ - data: rows, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onPaginationChange: setPagination, - state: { - pagination - } - }); + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + state: { + pagination + } + }); - return ( -
-
- {renderSearch()} - {onDownload && ( - - ) - } -
+ return ( +
+
+ {renderSearch()} + {onDownload && ( + + ) + } +
-
-
- -
-
- -
Page
- - {table.getState().pagination.pageIndex + 1} of{' '} - {table.getPageCount().toLocaleString()} - -
| Total: {table.getRowCount().toLocaleString()}
-
- -
-
+
+
+ +
+
+ +
Page
+ + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount().toLocaleString()} + +
| Total: {table.getRowCount().toLocaleString()}
+
+ +
+
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} - - ))} - -
-
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {{ - asc: , - desc: , - }[header.column.getIsSorted() as string] ?? null} -
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
-
- ) + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) } diff --git a/src/hooks/useListSearch.tsx b/src/hooks/useListSearch.tsx index af4ed1e4..fac9e688 100644 --- a/src/hooks/useListSearch.tsx +++ b/src/hooks/useListSearch.tsx @@ -1,11 +1,12 @@ -import {useState, useMemo} from "react"; +import { useState, useMemo } from "react"; import Input from "@/components/Low/Input"; -import {search} from "@/utils/search"; +import { search } from "@/utils/search"; -export function useListSearch(fields: string[][], rows: T[]) { +export function useListSearch(fields: string[][], rows: T[], placeholder?: string) { const [text, setText] = useState(""); - const renderSearch = () => ; + const renderSearch = () => + ; const updatedRows = useMemo(() => { if (text.length > 0) return search(text, fields, rows); diff --git a/src/pages/api/statistical.ts b/src/pages/api/statistical.ts new file mode 100644 index 00000000..1283b3d9 --- /dev/null +++ b/src/pages/api/statistical.ts @@ -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', + }, + }, +}; diff --git a/src/pages/dashboard/admin.tsx b/src/pages/dashboard/admin.tsx index 3d2d1722..d03de7ae 100644 --- a/src/pages/dashboard/admin.tsx +++ b/src/pages/dashboard/admin.tsx @@ -25,174 +25,179 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { useMemo } from "react"; import { - BsBank, - BsClipboard2Data, - BsClock, - BsEnvelopePaper, - BsPaperclip, - BsPencilSquare, - BsPeople, - BsPeopleFill, - BsPersonFill, - BsPersonFillGear, + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, } from "react-icons/bs"; import { ToastContainer } from "react-toastify"; interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; - assignments: Assignment[]; - stats: Stat[]; - groups: Group[]; + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; } export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") - if (!checkAccess(user, ["admin", "developer"])) return redirect("/") + if (!checkAccess(user, ["admin", "developer"])) return redirect("/") - const users = await getUsers(); - const entities = await getEntitiesWithRoles(); - const assignments = await getAssignments(); - const stats = await getStatsByUsers(users.map((u) => u.id)); - const groups = await getGroups(); + const users = await getUsers(); + const entities = await getEntitiesWithRoles(); + const assignments = await getAssignments(); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroups(); - return { props: serialize({ user, users, entities, assignments, stats, groups }) }; + return { props: serialize({ user, users, entities, assignments, stats, groups }) }; }, sessionOptions); export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); - const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); - const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); - const router = useRouter(); + const router = useRouter(); - const averageLevelCalculator = (studentStats: Stat[]) => { - const formattedStats = studentStats - .map((s) => ({ - focus: students.find((u) => u.id === s.user)?.focus, - score: s.score, - module: s.module, - })) - .filter((f) => !!f.focus); - const bandScores = formattedStats.map((s) => ({ - module: s.module, - level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), - })); + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); - const levels: { [key in Module]: number } = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, - level: 0, - }; - bandScores.forEach((b) => (levels[b.module] += b.level)); + const levels: { [key in Module]: number } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); - return calculateAverageLevel(levels); - }; + return calculateAverageLevel(levels); + }; - return ( - <> - - EnCoach - - - - - - -
- router.push("/users?type=student")} - Icon={BsPersonFill} - label="Students" - value={students.length} - color="purple" - /> - router.push("/users?type=teacher")} - Icon={BsPencilSquare} - label="Teachers" - value={teachers.length} - color="purple" - /> - router.push("/users?type=corporate")} - label="Corporates" - value={corporates.length} - color="purple" - /> - router.push("/users?type=mastercorporate")} - label="Master Corporates" - value={masterCorporates.length} - color="purple" - /> - router.push("/classrooms")} - label="Classrooms" - value={groups.length} - color="purple" - /> - router.push("/entities")} - label="Entities" - value={entities.length} - color="purple" - /> - - - router.push("/users/performance")} - label="Student Performance" + return ( + <> + + EnCoach + + + + + + +
+ router.push("/users?type=student")} + Icon={BsPersonFill} + label="Students" value={students.length} color="purple" /> - router.push("/assignments")} - label="Assignments" - value={assignments.filter((a) => !a.archived).length} - color="purple" - /> -
+ router.push("/users?type=teacher")} + Icon={BsPencilSquare} + label="Teachers" + value={teachers.length} + color="purple" + /> + router.push("/users?type=corporate")} + label="Corporates" + value={corporates.length} + color="purple" + /> + router.push("/users?type=mastercorporate")} + label="Master Corporates" + value={masterCorporates.length} + color="purple" + /> + router.push("/classrooms")} + label="Classrooms" + value={groups.length} + color="purple" + /> + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> + + router.push("/statistical")} + label="Entity Statistics" + value={entities.length} + color="purple" + /> + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + /> + router.push("/assignments")} + label="Assignments" + value={assignments.filter((a) => !a.archived).length} + color="purple" + /> +
-
- dateSorter(a, b, "desc", "registrationDate"))} - title="Latest Students" - /> - dateSorter(a, b, "desc", "registrationDate"))} - title="Latest Teachers" - /> - calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} - title="Highest level students" - /> - - Object.keys(groupByExam(filterBy(stats, "user", b))).length - - Object.keys(groupByExam(filterBy(stats, "user", a))).length, - ) - } - title="Highest exam count students" - /> -
-
- - ); +
+ dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + } + title="Highest exam count students" + /> +
+ + + ); } diff --git a/src/pages/dashboard/developer.tsx b/src/pages/dashboard/developer.tsx index 3d2d1722..d03de7ae 100644 --- a/src/pages/dashboard/developer.tsx +++ b/src/pages/dashboard/developer.tsx @@ -25,174 +25,179 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { useMemo } from "react"; import { - BsBank, - BsClipboard2Data, - BsClock, - BsEnvelopePaper, - BsPaperclip, - BsPencilSquare, - BsPeople, - BsPeopleFill, - BsPersonFill, - BsPersonFillGear, + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, } from "react-icons/bs"; import { ToastContainer } from "react-toastify"; interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; - assignments: Assignment[]; - stats: Stat[]; - groups: Group[]; + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; } export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") - if (!checkAccess(user, ["admin", "developer"])) return redirect("/") + if (!checkAccess(user, ["admin", "developer"])) return redirect("/") - const users = await getUsers(); - const entities = await getEntitiesWithRoles(); - const assignments = await getAssignments(); - const stats = await getStatsByUsers(users.map((u) => u.id)); - const groups = await getGroups(); + const users = await getUsers(); + const entities = await getEntitiesWithRoles(); + const assignments = await getAssignments(); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroups(); - return { props: serialize({ user, users, entities, assignments, stats, groups }) }; + return { props: serialize({ user, users, entities, assignments, stats, groups }) }; }, sessionOptions); export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); - const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); - const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); - const router = useRouter(); + const router = useRouter(); - const averageLevelCalculator = (studentStats: Stat[]) => { - const formattedStats = studentStats - .map((s) => ({ - focus: students.find((u) => u.id === s.user)?.focus, - score: s.score, - module: s.module, - })) - .filter((f) => !!f.focus); - const bandScores = formattedStats.map((s) => ({ - module: s.module, - level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), - })); + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); - const levels: { [key in Module]: number } = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, - level: 0, - }; - bandScores.forEach((b) => (levels[b.module] += b.level)); + const levels: { [key in Module]: number } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); - return calculateAverageLevel(levels); - }; + return calculateAverageLevel(levels); + }; - return ( - <> - - EnCoach - - - - - - -
- router.push("/users?type=student")} - Icon={BsPersonFill} - label="Students" - value={students.length} - color="purple" - /> - router.push("/users?type=teacher")} - Icon={BsPencilSquare} - label="Teachers" - value={teachers.length} - color="purple" - /> - router.push("/users?type=corporate")} - label="Corporates" - value={corporates.length} - color="purple" - /> - router.push("/users?type=mastercorporate")} - label="Master Corporates" - value={masterCorporates.length} - color="purple" - /> - router.push("/classrooms")} - label="Classrooms" - value={groups.length} - color="purple" - /> - router.push("/entities")} - label="Entities" - value={entities.length} - color="purple" - /> - - - router.push("/users/performance")} - label="Student Performance" + return ( + <> + + EnCoach + + + + + + +
+ router.push("/users?type=student")} + Icon={BsPersonFill} + label="Students" value={students.length} color="purple" /> - router.push("/assignments")} - label="Assignments" - value={assignments.filter((a) => !a.archived).length} - color="purple" - /> -
+ router.push("/users?type=teacher")} + Icon={BsPencilSquare} + label="Teachers" + value={teachers.length} + color="purple" + /> + router.push("/users?type=corporate")} + label="Corporates" + value={corporates.length} + color="purple" + /> + router.push("/users?type=mastercorporate")} + label="Master Corporates" + value={masterCorporates.length} + color="purple" + /> + router.push("/classrooms")} + label="Classrooms" + value={groups.length} + color="purple" + /> + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> + + router.push("/statistical")} + label="Entity Statistics" + value={entities.length} + color="purple" + /> + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + /> + router.push("/assignments")} + label="Assignments" + value={assignments.filter((a) => !a.archived).length} + color="purple" + /> +
-
- dateSorter(a, b, "desc", "registrationDate"))} - title="Latest Students" - /> - dateSorter(a, b, "desc", "registrationDate"))} - title="Latest Teachers" - /> - calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} - title="Highest level students" - /> - - Object.keys(groupByExam(filterBy(stats, "user", b))).length - - Object.keys(groupByExam(filterBy(stats, "user", a))).length, - ) - } - title="Highest exam count students" - /> -
-
- - ); +
+ dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + } + title="Highest exam count students" + /> +
+ + + ); } diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index bb8c7948..54814d11 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -26,180 +26,185 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { useMemo } from "react"; import { - BsBank, - BsClipboard2Data, - BsClock, - BsEnvelopePaper, - BsPaperclip, - BsPencilSquare, - BsPeople, - BsPeopleFill, - BsPersonFill, - BsPersonFillGear, + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, } from "react-icons/bs"; import { ToastContainer } from "react-toastify"; interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; - assignments: Assignment[]; - stats: Stat[]; - groups: Group[]; + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; } 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("/") + if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) + return redirect("/") - const entityIDS = mapBy(user.entities, "id") || []; - const entities = await getEntitiesWithRoles(entityIDS); - const users = await filterAllowedUsers(user, entities) + const entityIDS = mapBy(user.entities, "id") || []; + const entities = await getEntitiesWithRoles(entityIDS); + const users = await filterAllowedUsers(user, entities) - const assignments = await getEntitiesAssignments(entityIDS); - const stats = await getStatsByUsers(users.map((u) => u.id)); - const groups = await getGroupsByEntities(entityIDS); + const assignments = await getEntitiesAssignments(entityIDS); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroupsByEntities(entityIDS); - return { props: serialize({ user, users, entities, assignments, stats, groups }) }; + return { props: serialize({ user, users, entities, assignments, stats, groups }) }; }, sessionOptions); export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); - const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); - const router = useRouter(); + const router = useRouter(); - const averageLevelCalculator = (studentStats: Stat[]) => { - const formattedStats = studentStats - .map((s) => ({ - focus: students.find((u) => u.id === s.user)?.focus, - score: s.score, - module: s.module, - })) - .filter((f) => !!f.focus); - const bandScores = formattedStats.map((s) => ({ - module: s.module, - level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), - })); + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); - const levels: { [key in Module]: number } = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, - level: 0, - }; - bandScores.forEach((b) => (levels[b.module] += b.level)); + const levels: { [key in Module]: number } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); - return calculateAverageLevel(levels); - }; + return calculateAverageLevel(levels); + }; - const UserDisplay = (displayUser: User) => ( -
- {displayUser.name} -
- {displayUser.name} - {displayUser.email} -
-
- ); + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); - return ( - <> - - EnCoach - - - - - - -
- router.push("/users?type=student")} - Icon={BsPersonFill} - label="Students" - value={students.length} - color="purple" - /> - router.push("/users?type=teacher")} - Icon={BsPencilSquare} - label="Teachers" - value={teachers.length} - color="purple" - /> - router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> - router.push("/classrooms")} - label="Classrooms" - value={groups.length} - color="purple" - /> - router.push("/entities")} - label="Entities" - value={entities.length} - color="purple" - /> - - - router.push("/users/performance")} - label="Student Performance" + return ( + <> + + EnCoach + + + + + + +
+ router.push("/users?type=student")} + Icon={BsPersonFill} + label="Students" value={students.length} color="purple" /> - router.push("/assignments")} - label="Assignments" - value={assignments.filter((a) => !a.archived).length} - color="purple" - /> - -
+ router.push("/users?type=teacher")} + Icon={BsPencilSquare} + label="Teachers" + value={teachers.length} + color="purple" + /> + router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> + router.push("/classrooms")} + label="Classrooms" + value={groups.length} + color="purple" + /> + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> + + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + /> + router.push("/statistical")} + label="Entity Statistics" + value={entities.length} + color="purple" + /> + router.push("/assignments")} + label="Assignments" + value={assignments.filter((a) => !a.archived).length} + color="purple" + /> + +
-
- dateSorter(a, b, "desc", "registrationDate"))} - title="Latest Students" - /> - dateSorter(a, b, "desc", "registrationDate"))} - title="Latest Teachers" - /> - calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} - title="Highest level students" - /> - - Object.keys(groupByExam(filterBy(stats, "user", b))).length - - Object.keys(groupByExam(filterBy(stats, "user", a))).length, - ) - } - title="Highest exam count students" - /> -
-
- - ); +
+ dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + } + title="Highest exam count students" + /> +
+ + + ); } diff --git a/src/pages/statistical.tsx b/src/pages/statistical.tsx new file mode 100644 index 00000000..732009d9 --- /dev/null +++ b/src/pages/statistical.tsx @@ -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(); + +export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) { + const [startDate, setStartDate] = useState(new Date()); + const [endDate, setEndDate] = useState(moment().add(1, 'month').toDate()); + const [selectedEntities, setSelectedEntities] = useState([]) + + 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 Never logged in + if (info.getValue()) return Submitted + if (!session) return Not started + + return + {capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1} + + }, + }), + 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 ( + <> + + Statistical | EnCoach + + + + + + +
+
+
+ + + +

Statistical

+
+ setSelectedEntities(value ? mapBy(entities, 'id') : [])} + isChecked={selectedEntities.length === entities.length} + > + Select All + +
+ +
+ +
+
+ {entities.map(entity => ( + + ))} +
+ +
+
+ + {startDate !== null && endDate !== null && ( + + )} +
+ + Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total} + +
+
+ + {selectedEntities.length > 0 && ( + + )} + + + ) +} diff --git a/src/utils/sessions.be.ts b/src/utils/sessions.be.ts index 082f40f3..2d84dd74 100644 --- a/src/utils/sessions.be.ts +++ b/src/utils/sessions.be.ts @@ -1,4 +1,4 @@ -import {Session} from "@/hooks/useSessions"; +import { Session } from "@/hooks/useSessions"; import client from "@/lib/mongodb"; 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 = {}) => await db .collection("sessions") - .find({user: id, ...filter}) + .find({ user: id, ...filter }) .limit(limit || 0) .toArray(); export const getSessionByAssignment = async (assignmentID: string) => await db - .collection("sessions") - .findOne({"assignment.id": assignmentID}) + .collection("sessions") + .findOne({ "assignment.id": assignmentID }) + +export const getSessionsByAssignments = async (assignmentIDs: string[]) => + await db + .collection("sessions") + .find({ "assignment.id": { $in: assignmentIDs } }).toArray()