Fix student performance freeze and search users in create entities

TODO: pagination in student performance freeze
This commit is contained in:
José Lima
2025-03-04 23:12:26 +00:00
parent 655e019bf6
commit c49b1c8070
8 changed files with 220 additions and 140 deletions

View File

@@ -114,5 +114,6 @@
"husky": "^8.0.3", "husky": "^8.0.3",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.2.4"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@@ -1,110 +1,158 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import {Stat, StudentUser, User} from "@/interfaces/user"; import { Stat, StudentUser, User } from "@/interfaces/user";
import {useState} from "react"; import { useState } from "react";
import {averageLevelCalculator} from "@/utils/score"; import { averageLevelCalculator } from "@/utils/score";
import {groupByExam} from "@/utils/stats"; import { groupByExam } from "@/utils/stats";
import {createColumnHelper} from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
import Checkbox from "@/components/Low/Checkbox"; import Checkbox from "@/components/Low/Checkbox";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string}; type StudentPerformanceItem = StudentUser & {
entitiesLabel: string;
group: string;
userStats: Stat[];
};
const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => { const StudentPerformanceList = ({
const [isShowingAmount, setIsShowingAmount] = useState(false); items = [],
}: {
items: StudentPerformanceItem[];
}) => {
const [isShowingAmount, setIsShowingAmount] = useState(false);
const columnHelper = createColumnHelper<StudentPerformanceItem>(); const columnHelper = createColumnHelper<StudentPerformanceItem>();
const columns = [ const columns = [
columnHelper.accessor("name", { columnHelper.accessor("name", {
header: "Student Name", header: "Student Name",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: "E-mail", header: "E-mail",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("studentID", { columnHelper.accessor("studentID", {
header: "ID", header: "ID",
cell: (info) => info.getValue() || "N/A", cell: (info) => info.getValue() || "N/A",
}), }),
columnHelper.accessor("group", { columnHelper.accessor("group", {
header: "Group", header: "Group",
cell: (info) => info.getValue(), cell: (info) => info.getValue(),
}), }),
columnHelper.accessor("entitiesLabel", { columnHelper.accessor("entitiesLabel", {
header: "Entities", header: "Entities",
cell: (info) => info.getValue() || "N/A", cell: (info) => info.getValue() || "N/A",
}), }),
columnHelper.accessor("levels.reading", { columnHelper.accessor("levels.reading", {
header: "Reading", header: "Reading",
cell: (info) => cell: (info) =>
!isShowingAmount !isShowingAmount
? info.getValue() || 0 ? info.getValue() || 0
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`, : `${
}), Object.keys(
columnHelper.accessor("levels.listening", { groupByExam(
header: "Listening", info.row.original.userStats.filter(
cell: (info) => (x) => x.module === "reading"
!isShowingAmount )
? info.getValue() || 0 )
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`, ).length
}), } exams`,
columnHelper.accessor("levels.writing", { }),
header: "Writing", columnHelper.accessor("levels.listening", {
cell: (info) => header: "Listening",
!isShowingAmount cell: (info) =>
? info.getValue() || 0 !isShowingAmount
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`, ? info.getValue() || 0
}), : `${
columnHelper.accessor("levels.speaking", { Object.keys(
header: "Speaking", groupByExam(
cell: (info) => info.row.original.userStats.filter(
!isShowingAmount (x) => x.module === "listening"
? info.getValue() || 0 )
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`, )
}), ).length
columnHelper.accessor("levels.level", { } exams`,
header: "Level", }),
cell: (info) => columnHelper.accessor("levels.writing", {
!isShowingAmount header: "Writing",
? info.getValue() || 0 cell: (info) =>
: `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`, !isShowingAmount
}), ? info.getValue() || 0
columnHelper.accessor("levels", { : `${
id: "overall_level", Object.keys(
header: "Overall", groupByExam(
cell: (info) => info.row.original.userStats.filter(
!isShowingAmount (x) => x.module === "writing"
? averageLevelCalculator( )
items, )
stats.filter((x) => x.user === info.row.original.id), ).length
).toFixed(1) } exams`,
: `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`, }),
}), columnHelper.accessor("levels.speaking", {
]; header: "Speaking",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "speaking"
)
)
).length
} exams`,
}),
columnHelper.accessor("levels.level", {
header: "Level",
cell: (info) =>
!isShowingAmount
? info.getValue() || 0
: `${
Object.keys(
groupByExam(
info.row.original.userStats.filter(
(x) => x.module === "level"
)
)
).length
} exams`,
}),
columnHelper.accessor("userStats", {
id: "overall_level",
header: "Overall",
cell: (info) =>
!isShowingAmount
? averageLevelCalculator(
info.row.original.focus,
info.getValue()
).toFixed(1)
: `${Object.keys(groupByExam(info.getValue())).length} exams`,
}),
];
return ( return (
<div className="flex flex-col gap-4 w-full h-full"> <div className="flex flex-col gap-4 w-full h-full">
<Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}> <Checkbox isChecked={isShowingAmount} onChange={setIsShowingAmount}>
Show Utilization Show Utilization
</Checkbox> </Checkbox>
<Table<StudentPerformanceItem> <Table<StudentPerformanceItem>
data={items.sort( data={items.sort(
(a, b) => (a, b) =>
averageLevelCalculator( averageLevelCalculator(b.focus, b.userStats) -
items, averageLevelCalculator(a.focus, a.userStats)
stats.filter((x) => x.user === b.id), )}
) - columns={columns}
averageLevelCalculator( searchFields={[
items, ["name"],
stats.filter((x) => x.user === a.id), ["email"],
), ["studentID"],
)} ["entitiesLabel"],
columns={columns} ["group"],
searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]} ]}
/> />
</div> </div>
); );
}; };
export default StudentPerformanceList; export default StudentPerformanceList;

View File

@@ -70,7 +70,7 @@ export default function Home({ user, users }: Props) {
const [licenses, setLicenses] = useState(0); const [licenses, setLicenses] = useState(0);
const { rows, renderSearch } = useListSearch<User>( const { rows, renderSearch } = useListSearch<User>(
[["name"], ["corporateInformation", "companyInformation", "name"]], [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]],
users users
); );
const { items, renderMinimal } = usePagination<User>(rows, 16); const { items, renderMinimal } = usePagination<User>(rows, 16);

View File

@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import { BsChevronLeft } from "react-icons/bs"; import { BsChevronLeft } from "react-icons/bs";
import { mapBy, serialize } from "@/utils"; import { mapBy, serialize } from "@/utils";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getUsersWithStats } from "@/utils/users.be";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
@@ -33,8 +33,32 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (allowedEntities.length === 0) return redirect("/"); if (allowedEntities.length === 0) return redirect("/");
const students = await (checkAccess(user, ["admin", "developer"]) const students = await (checkAccess(user, ["admin", "developer"])
? getUsers({ type: "student" }) ? getUsersWithStats(
: getEntitiesUsers(mapBy(allowedEntities, "id"), { type: "student" })); { type: "student" },
{
id: 1,
entities: 1,
focus: 1,
email: 1,
name: 1,
levels: 1,
userStats: 1,
studentID: 1,
}
)
: getUsersWithStats(
{ type: "student", "entities.id": { in: mapBy(entities, "id") } },
{
id: 1,
entities: 1,
focus: 1,
email: 1,
name: 1,
levels: 1,
userStats: 1,
studentID: 1,
}
));
const groups = await getParticipantsGroups(mapBy(students, "id")); const groups = await getParticipantsGroups(mapBy(students, "id"));
return { return {
@@ -44,14 +68,12 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
interface Props { interface Props {
user: User; user: User;
students: StudentUser[]; students: (StudentUser & { userStats: Stat[] })[];
entities: Entity[]; entities: Entity[];
groups: Group[]; groups: Group[];
} }
const StudentPerformance = ({ user, students, entities, groups }: Props) => { const StudentPerformance = ({ students, entities, groups }: Props) => {
const { data: stats } = useFilterRecordsByUser<Stat[]>();
const router = useRouter(); const router = useRouter();
const performanceStudents = students.map((u) => ({ const performanceStudents = students.map((u) => ({
@@ -76,7 +98,6 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
</Head> </Head>
<ToastContainer /> <ToastContainer />
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -91,7 +112,7 @@ const StudentPerformance = ({ user, students, entities, groups }: Props) => {
Student Performance ({students.length}) Student Performance ({students.length})
</h2> </h2>
</div> </div>
<StudentPerformanceList items={performanceStudents} stats={stats} /> <StudentPerformanceList items={performanceStudents} />
</> </>
</> </>
); );

View File

@@ -1,4 +1,4 @@
import {Module} from "@/interfaces"; import { Module } from "@/interfaces";
import { import {
Exam, Exam,
ReadingExam, ReadingExam,
@@ -70,9 +70,9 @@ export const getExamById = async (module: Module, id: string): Promise<Exam | un
export const defaultExamUserSolutions = (exam: Exam) => { export const defaultExamUserSolutions = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") if (exam.module === "reading" || exam.module === "listening" || exam.module === "level")
return exam.parts.flatMap((x) => x.exercises).map((x) => defaultUserSolutions(x, exam)); return (exam.parts.flatMap((x) => x.exercises) ?? []).map((x) => defaultUserSolutions(x, exam));
return exam.exercises.map((x) => defaultUserSolutions(x, exam)); return (exam.exercises ?? []).map((x) => defaultUserSolutions(x, exam));
}; };
export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => { export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSolution => {
@@ -88,26 +88,26 @@ export const defaultUserSolutions = (exercise: Exercise, exam: Exam): UserSoluti
switch (exercise.type) { switch (exercise.type) {
case "fillBlanks": case "fillBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0; total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
case "matchSentences": case "matchSentences":
total = exercise.sentences.length; total = exercise.sentences.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
case "multipleChoice": case "multipleChoice":
total = exercise.questions.length; total = exercise.questions.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
case "writeBlanks": case "writeBlanks":
total = exercise.text.match(/({{\d+}})/g)?.length || 0; total = exercise.text.match(/({{\d+}})/g)?.length || 0;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
case "trueFalse": case "trueFalse":
total = exercise.questions.length; total = exercise.questions.length;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
case "writing": case "writing":
total = 1; total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
case "speaking": case "speaking":
total = 1; total = 1;
return {...defaultSettings, score: {correct: 0, total, missing: total}}; return { ...defaultSettings, score: { correct: 0, total, missing: total } };
default: default:
return {...defaultSettings, score: {correct: 0, total: 0, missing: 0}}; return { ...defaultSettings, score: { correct: 0, total: 0, missing: 0 } };
} }
}; };

View File

@@ -1,9 +1,9 @@
import {Module, Step} from "@/interfaces"; import { Module, Step } from "@/interfaces";
import {Stat, User} from "@/interfaces/user"; import { Stat, User } from "@/interfaces/user";
type Type = "academic" | "general"; type Type = "academic" | "general";
export const writingReverseMarking: {[key: number]: number} = { export const writingReverseMarking: { [key: number]: number } = {
9: 90, 9: 90,
8.5: 85, 8.5: 85,
8: 80, 8: 80,
@@ -25,7 +25,7 @@ export const writingReverseMarking: {[key: number]: number} = {
0: 0, 0: 0,
}; };
export const speakingReverseMarking: {[key: number]: number} = { export const speakingReverseMarking: { [key: number]: number } = {
9: 90, 9: 90,
8.5: 85, 8.5: 85,
8: 80, 8: 80,
@@ -47,7 +47,7 @@ export const speakingReverseMarking: {[key: number]: number} = {
0: 0, 0: 0,
}; };
export const writingMarking: {[key: number]: number} = { export const writingMarking: { [key: number]: number } = {
90: 9, 90: 9,
80: 8, 80: 8,
70: 7, 70: 7,
@@ -60,7 +60,7 @@ export const writingMarking: {[key: number]: number} = {
0: 0, 0: 0,
}; };
const readingGeneralMarking: {[key: number]: number} = { const readingGeneralMarking: { [key: number]: number } = {
100: 9, 100: 9,
97.5: 8.5, 97.5: 8.5,
92.5: 8, 92.5: 8,
@@ -77,7 +77,7 @@ const readingGeneralMarking: {[key: number]: number} = {
15: 2.5, 15: 2.5,
}; };
const academicMarking: {[key: number]: number} = { const academicMarking: { [key: number]: number } = {
97.5: 9, 97.5: 9,
92.5: 8.5, 92.5: 8.5,
87.5: 8, 87.5: 8,
@@ -94,7 +94,7 @@ const academicMarking: {[key: number]: number} = {
10: 2.5, 10: 2.5,
}; };
const levelMarking: {[key: number]: number} = { const levelMarking: { [key: number]: number } = {
88: 9, // Advanced 88: 9, // Advanced
64: 8, // Upper-Intermediate 64: 8, // Upper-Intermediate
52: 6, // Intermediate 52: 6, // Intermediate
@@ -103,7 +103,7 @@ const levelMarking: {[key: number]: number} = {
0: 0, // Beginner 0: 0, // Beginner
}; };
const moduleMarkings: {[key in Module | "overall"]: {[key in Type]: {[key: number]: number}}} = { const moduleMarkings: { [key in Module | "overall"]: { [key in Type]: { [key: number]: number } } } = {
reading: { reading: {
academic: academicMarking, academic: academicMarking,
general: readingGeneralMarking, general: readingGeneralMarking,
@@ -147,7 +147,7 @@ export const calculateBandScore = (correct: number, total: number, module: Modul
return 0; return 0;
}; };
export const calculateAverageLevel = (levels: {[key in Module]: number}) => { export const calculateAverageLevel = (levels: { [key in Module]: number }) => {
return ( return (
Object.keys(levels) Object.keys(levels)
.filter((x) => x !== "level") .filter((x) => x !== "level")
@@ -193,20 +193,21 @@ export const getGradingLabel = (score: number, grading: Step[]) => {
return "N/A"; return "N/A";
}; };
export const averageLevelCalculator = (users: User[], studentStats: Stat[]) => { export const averageLevelCalculator = (focus: Type, studentStats: Stat[]) => {
const formattedStats = studentStats /* const formattedStats = studentStats
.map((s) => ({ .map((s) => ({
focus: users.find((u) => u.id === s.user)?.focus, focus: 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 = studentStats.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, 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,

View File

@@ -6,7 +6,7 @@ import client from "@/lib/mongodb";
import { EntityWithRoles, WithEntities } from "@/interfaces/entity"; import { EntityWithRoles, WithEntities } from "@/interfaces/entity";
import { getEntity } from "./entities.be"; import { getEntity } from "./entities.be";
import { getRole } from "./roles.be"; import { getRole } from "./roles.be";
import { groupAllowedEntitiesByPermissions } from "./permissions"; import { groupAllowedEntitiesByPermissions } from "./permissions";
import { mapBy } from "."; import { mapBy } from ".";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -20,6 +20,15 @@ export async function getUsers(filter?: object, limit = 0, sort = {}, projection
.toArray(); .toArray();
} }
export async function getUsersWithStats(filter?: object, projection = {}, limit = 0, sort = {}) {
return await db
.collection("usersWithStats")
.find<User>(filter || {}, { projection: { _id: 0, ...projection } })
.limit(limit)
.sort(sort)
.toArray();
}
export async function searchUsers(searchInput?: string, limit = 50, page = 0, sort: object = { "name": 1 }, projection = {}, filter?: object) { export async function searchUsers(searchInput?: string, limit = 50, page = 0, sort: object = { "name": 1 }, projection = {}, filter?: object) {
const compoundFilter = { const compoundFilter = {
"compound": { "compound": {

View File

@@ -36,7 +36,7 @@
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx" "**/*.tsx"
], , "scripts/updatePrivateFieldExams.js" ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
] ]