Revamped the statistical page to work with the new entity system, along with some other improvements to it
This commit is contained in:
@@ -11,15 +11,16 @@ interface Props<T> {
|
|||||||
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,
|
||||||
@@ -39,7 +40,7 @@ export default function Table<T>({ data, columns, searchFields, size = 16, onDow
|
|||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
191
src/pages/api/statistical.ts
Normal file
191
src/pages/api/statistical.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -151,7 +151,12 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").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("/statistical")}
|
||||||
|
label="Entity Statistics"
|
||||||
|
value={entities.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
<IconCard Icon={BsPersonFillGear}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/users/performance")}
|
onClick={() => router.push("/users/performance")}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
|
|||||||
@@ -151,7 +151,12 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").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("/statistical")}
|
||||||
|
label="Entity Statistics"
|
||||||
|
value={entities.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
<IconCard Icon={BsPersonFillGear}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/users/performance")}
|
onClick={() => router.push("/users/performance")}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
|
|||||||
@@ -152,13 +152,18 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
|
|||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<IconCard Icon={BsClipboard2Data} label="Exams Performed" value={uniqBy(stats, "exam").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}
|
<IconCard Icon={BsPersonFillGear}
|
||||||
onClick={() => router.push("/users/performance")}
|
onClick={() => router.push("/users/performance")}
|
||||||
label="Student Performance"
|
label="Student Performance"
|
||||||
value={students.length}
|
value={students.length}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
|
<IconCard Icon={BsPersonFillGear}
|
||||||
|
onClick={() => router.push("/statistical")}
|
||||||
|
label="Entity Statistics"
|
||||||
|
value={entities.length}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
<IconCard
|
<IconCard
|
||||||
Icon={BsEnvelopePaper}
|
Icon={BsEnvelopePaper}
|
||||||
onClick={() => router.push("/assignments")}
|
onClick={() => router.push("/assignments")}
|
||||||
|
|||||||
298
src/pages/statistical.tsx
Normal file
298
src/pages/statistical.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import Layout from "@/components/High/Layout";
|
||||||
|
import Table from "@/components/High/Table";
|
||||||
|
import Checkbox from "@/components/Low/Checkbox";
|
||||||
|
import Separator from "@/components/Low/Separator";
|
||||||
|
import { Session } from "@/hooks/useSessions";
|
||||||
|
import { Entity, EntityWithRoles } from "@/interfaces/entity";
|
||||||
|
import { Exam } from "@/interfaces/exam";
|
||||||
|
import { Assignment, AssignmentResult } from "@/interfaces/results";
|
||||||
|
import { Group, Stat, StudentUser, User } from "@/interfaces/user";
|
||||||
|
import { sessionOptions } from "@/lib/session";
|
||||||
|
import { filterBy, findBy, mapBy, redirect, serialize } from "@/utils";
|
||||||
|
import { requestUser } from "@/utils/api";
|
||||||
|
import { getEntitiesAssignments } from "@/utils/assignments.be";
|
||||||
|
import { getEntitiesWithRoles } from "@/utils/entities.be";
|
||||||
|
import { getExamsByIds } from "@/utils/exams.be";
|
||||||
|
import { checkAccess, findAllowedEntities } from "@/utils/permissions";
|
||||||
|
import { getSessionsByAssignments, getSessionsByUser } from "@/utils/sessions.be";
|
||||||
|
import { getStatsByUsers } from "@/utils/stats.be";
|
||||||
|
import { isAdmin } from "@/utils/users";
|
||||||
|
import { getEntitiesUsers } from "@/utils/users.be";
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import axios from "axios";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { withIronSessionSsr } from "iron-session/next";
|
||||||
|
import { capitalize, orderBy } from "lodash";
|
||||||
|
import moment from "moment";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import {
|
||||||
|
BsBank,
|
||||||
|
BsChevronLeft,
|
||||||
|
BsX,
|
||||||
|
} from "react-icons/bs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
students: StudentUser[];
|
||||||
|
entities: EntityWithRoles[];
|
||||||
|
assignments: Assignment[];
|
||||||
|
sessions: Session[]
|
||||||
|
exams: Exam[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||||
|
const user = await requestUser(req, res)
|
||||||
|
if (!user) return redirect("/login")
|
||||||
|
|
||||||
|
if (!checkAccess(user, ["admin", "developer", "mastercorporate"]))
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
const entityIDS = mapBy(user.entities, "id") || [];
|
||||||
|
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
|
||||||
|
|
||||||
|
const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students')
|
||||||
|
const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" })
|
||||||
|
|
||||||
|
const assignments = await getEntitiesAssignments(mapBy(entities, "id"));
|
||||||
|
const sessions = await getSessionsByAssignments(mapBy(assignments, 'id'))
|
||||||
|
const exams = await getExamsByIds(assignments.flatMap(a => a.exams))
|
||||||
|
|
||||||
|
return { props: serialize({ user, students, entities, assignments, sessions, exams }) };
|
||||||
|
}, sessionOptions);
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
student: StudentUser
|
||||||
|
result?: AssignmentResult
|
||||||
|
assignment: Assignment
|
||||||
|
exams: Exam[]
|
||||||
|
session?: Session
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Item>();
|
||||||
|
|
||||||
|
export default function Statistical({ user, students, entities, assignments, sessions, exams }: Props) {
|
||||||
|
const [startDate, setStartDate] = useState<Date>(new Date());
|
||||||
|
const [endDate, setEndDate] = useState<Date | null>(moment().add(1, 'month').toDate());
|
||||||
|
const [selectedEntities, setSelectedEntities] = useState<string[]>([])
|
||||||
|
|
||||||
|
const resetDateRange = () => {
|
||||||
|
const orderedAssignments = orderBy(assignments, ['startDate'], ['asc'])
|
||||||
|
setStartDate(moment(orderedAssignments.shift()?.startDate || "2024-01-01T00:00:01.986Z").toDate())
|
||||||
|
setEndDate(moment().add(1, 'month').toDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(resetDateRange, [assignments])
|
||||||
|
|
||||||
|
const updateDateRange = (dates: [Date, Date | null]) => {
|
||||||
|
const [start, end] = dates;
|
||||||
|
setStartDate(start!);
|
||||||
|
setEndDate(end);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEntity = (id: string) => setSelectedEntities(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||||
|
|
||||||
|
const renderAssignmentResolution = (entityID: string) => {
|
||||||
|
const entityAssignments = filterBy(assignments, 'entity', entityID)
|
||||||
|
const total = entityAssignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
|
||||||
|
const results = entityAssignments.reduce((acc, curr) => acc + curr.results.length, 0)
|
||||||
|
|
||||||
|
return `${results}/${total}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAssignmentResolution = useMemo(() => {
|
||||||
|
const total = assignments.reduce((acc, curr) => acc + curr.assignees.length, 0)
|
||||||
|
const results = assignments.reduce((acc, curr) => acc + curr.results.length, 0)
|
||||||
|
|
||||||
|
return { results, total }
|
||||||
|
}, [assignments])
|
||||||
|
|
||||||
|
const filteredAssignments = useMemo(() => {
|
||||||
|
if (!startDate && !endDate) return assignments
|
||||||
|
const startDateFiltered = startDate ? assignments.filter(a => moment(a.startDate).isSameOrAfter(moment(startDate))) : assignments
|
||||||
|
return endDate ? startDateFiltered.filter(a => moment(a.endDate).isSameOrBefore(moment(endDate))) : startDateFiltered
|
||||||
|
}, [startDate, endDate, assignments])
|
||||||
|
|
||||||
|
const data: Item[] = useMemo(() =>
|
||||||
|
filteredAssignments.filter(a => selectedEntities.includes(a.entity || "")).flatMap(a => a.assignees.map(x => {
|
||||||
|
const result = findBy(a.results, 'user', x)
|
||||||
|
const student = findBy(students, 'id', x)
|
||||||
|
const assignmentExams = exams.filter(e => a.exams.map(x => `${x.id}_${x.module}`).includes(`${e.id}_${e.module}`))
|
||||||
|
const session = sessions.find(s => s.assignment?.id === a.id && s.user === x)
|
||||||
|
|
||||||
|
if (!student) return undefined
|
||||||
|
return { student, result, assignment: a, exams: assignmentExams, session }
|
||||||
|
})).filter(x => !!x) as Item[],
|
||||||
|
[students, selectedEntities, filteredAssignments, exams, sessions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const sortedData: Item[] = useMemo(() => data.sort((a, b) => {
|
||||||
|
const aTotalScore = a.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0
|
||||||
|
const bTotalScore = b.result?.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0) || 0
|
||||||
|
|
||||||
|
return bTotalScore - aTotalScore
|
||||||
|
}), [data])
|
||||||
|
|
||||||
|
const downloadExcel = async () => {
|
||||||
|
const request = await axios.post("/api/statistical", {
|
||||||
|
entities: entities.filter(e => selectedEntities.includes(e.id)),
|
||||||
|
items: data,
|
||||||
|
assignments: filteredAssignments,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
}, {
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
|
||||||
|
const href = URL.createObjectURL(request.data)
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = href;
|
||||||
|
link.setAttribute('download', `statistical_${new Date().toISOString()}.xlsx`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor("student.name", {
|
||||||
|
header: "Student",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("student.studentID", {
|
||||||
|
header: "Student ID",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("student.email", {
|
||||||
|
header: "E-mail",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("assignment.name", {
|
||||||
|
header: "Assignment",
|
||||||
|
cell: (info) => info.getValue(),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("result", {
|
||||||
|
header: "Progress",
|
||||||
|
cell: (info) => {
|
||||||
|
const student = info.row.original.student
|
||||||
|
const session = info.row.original.session
|
||||||
|
|
||||||
|
if (!student.lastLogin) return <span className="text-mti-red-dark">Never logged in</span>
|
||||||
|
if (info.getValue()) return <span className="text-mti-green font-semibold">Submitted</span>
|
||||||
|
if (!session) return <span className="text-mti-rose">Not started</span>
|
||||||
|
|
||||||
|
return <span className="font-semibold">
|
||||||
|
{capitalize(session.exam?.module || "")} Module, Part {session.partIndex + 1}, Exercise {session.exerciseIndex + 1}
|
||||||
|
</span>
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("result", {
|
||||||
|
header: "Score",
|
||||||
|
cell: (info) => {
|
||||||
|
if (!info.getValue()) return null
|
||||||
|
const correct = info.getValue()!.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.correct, 0)
|
||||||
|
const total = info.getValue()!.stats.filter(x => !x.isPractice).reduce((acc, curr) => acc + curr.score.total, 0)
|
||||||
|
|
||||||
|
return `${correct} / ${total}`
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Statistical | EnCoach</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A training platform for the IELTS exam provided by the Muscat Training Institute and developed by eCrop."
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Layout user={user}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/dashboard" className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl">
|
||||||
|
<BsChevronLeft />
|
||||||
|
</Link>
|
||||||
|
<h2 className="font-bold text-2xl">Statistical</h2>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
onChange={value => setSelectedEntities(value ? mapBy(entities, 'id') : [])}
|
||||||
|
isChecked={selectedEntities.length === entities.length}
|
||||||
|
>
|
||||||
|
Select All
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="w-full flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
{entities.map(entity => (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleEntity(entity.id)}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col items-center justify-between gap-3 border-2 drop-shadow rounded-xl bg-white p-8 px-2 w-48 h-52",
|
||||||
|
"transition ease-in-out duration-300 hover:shadow-xl hover:border-mti-purple",
|
||||||
|
selectedEntities.includes(entity.id) && "border-mti-purple text-mti-purple"
|
||||||
|
)}
|
||||||
|
key={entity.id}
|
||||||
|
>
|
||||||
|
<BsBank size={48} />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>{entity.label}</span>
|
||||||
|
<span className={clsx("font-semibold")}>
|
||||||
|
{renderAssignmentResolution(entity.id)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ReactDatePicker
|
||||||
|
className={clsx(
|
||||||
|
"p-6 px-12 w-full flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer",
|
||||||
|
"hover:border-mti-purple tooltip",
|
||||||
|
"transition duration-300 ease-in-out",
|
||||||
|
)}
|
||||||
|
dateFormat="dd/MM/yyyy"
|
||||||
|
selectsRange
|
||||||
|
selected={startDate}
|
||||||
|
onChange={updateDateRange}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
/>
|
||||||
|
{startDate !== null && endDate !== null && (
|
||||||
|
<button onClick={resetDateRange} className="transition ease-in-out duration-300 rounded-full p-2 hover:bg-mti-gray-cool/10">
|
||||||
|
<BsX size={24} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-lg pr-1">
|
||||||
|
Total: {totalAssignmentResolution.results} / {totalAssignmentResolution.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedEntities.length > 0 && (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={sortedData}
|
||||||
|
searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]}
|
||||||
|
searchPlaceholder="Search by student, assignment or exam..."
|
||||||
|
onDownload={downloadExcel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user