diff --git a/src/components/High/Table.tsx b/src/components/High/Table.tsx index 4ca9f780..64e166ef 100644 --- a/src/components/High/Table.tsx +++ b/src/components/High/Table.tsx @@ -11,10 +11,11 @@ interface Props { searchFields: string[][] size?: number onDownload?: (rows: T[]) => void + isDownloadLoading?: boolean searchPlaceholder?: string } -export default function Table({ data, columns, searchFields, size = 16, onDownload, searchPlaceholder }: Props) { +export default function Table({ data, columns, searchFields, size = 16, onDownload, isDownloadLoading, searchPlaceholder }: Props) { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: size, @@ -39,7 +40,7 @@ export default function Table({ data, columns, searchFields, size = 16, onDow
{renderSearch()} {onDownload && ( - ) diff --git a/src/components/Medium/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx index be9da7b7..95c7b394 100644 --- a/src/components/Medium/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -77,7 +77,6 @@ interface StatsGridItemProps { assignments: Assignment[]; users: User[]; training?: boolean; - gradingSystem?: Step[]; selectedTrainingExams?: string[]; maxTrainingExams?: number; setSelectedTrainingExams?: React.Dispatch>; @@ -98,7 +97,6 @@ const StatsGridItem: React.FC = ({ users, training, selectedTrainingExams, - gradingSystem, setSelectedTrainingExams, setExams, setShowSolutions, diff --git a/src/dashboards/Student.tsx b/src/dashboards/Student.tsx index 14bf963e..210111b0 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -6,28 +6,28 @@ import useAssignments from "@/hooks/useAssignments"; import useGradingSystem from "@/hooks/useGrading"; import useInvites from "@/hooks/useInvites"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; -import useUsers, { userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers"; -import {Invite} from "@/interfaces/invite"; -import {Assignment} from "@/interfaces/results"; -import {CorporateUser, MasterCorporateUser, Stat, User} from "@/interfaces/user"; +import useUsers, { userHashStudent, userHashTeacher, userHashCorporate } from "@/hooks/useUsers"; +import { Invite } from "@/interfaces/invite"; +import { Assignment } from "@/interfaces/results"; +import { CorporateUser, MasterCorporateUser, Stat, User } from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import {getExamById} from "@/utils/exams"; -import {getUserCorporate} from "@/utils/groups"; -import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; -import {getGradingLabel, getLevelLabel, getLevelScore} from "@/utils/score"; -import {averageScore, groupBySession} from "@/utils/stats"; -import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; -import {PayPalButtons} from "@paypal/react-paypal-js"; +import { getExamById } from "@/utils/exams"; +import { getUserCorporate } from "@/utils/groups"; +import { countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName } from "@/utils/moduleUtils"; +import { getGradingLabel, getLevelLabel, getLevelScore } from "@/utils/score"; +import { averageScore, groupBySession } from "@/utils/stats"; +import { CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody } from "@paypal/paypal-js"; +import { PayPalButtons } from "@paypal/react-paypal-js"; import axios from "axios"; import clsx from "clsx"; -import {capitalize} from "lodash"; +import { capitalize } from "lodash"; import moment from "moment"; import Link from "next/link"; -import {useRouter} from "next/router"; -import {useEffect, useMemo, useState} from "react"; -import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; -import {toast} from "react-toastify"; -import {activeAssignmentFilter} from "@/utils/assignments"; +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; +import { BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar } from "react-icons/bs"; +import { toast } from "react-toastify"; +import { activeAssignmentFilter } from "@/utils/assignments"; import ModuleBadge from "@/components/ModuleBadge"; import useSessions from "@/hooks/useSessions"; @@ -36,15 +36,14 @@ interface Props { linkedCorporate?: CorporateUser | MasterCorporateUser; } -export default function StudentDashboard({user, linkedCorporate}: Props) { - const {gradingSystem} = useGradingSystem(); - const {sessions} = useSessions(user.id); - const {data: stats} = useFilterRecordsByUser(user.id, !user?.id); - const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); - const {invites, isLoading: isInvitesLoading, reload: reloadInvites} = useInvites({to: user.id}); +export default function StudentDashboard({ user, linkedCorporate }: Props) { + const { sessions } = useSessions(user.id); + const { data: stats } = useFilterRecordsByUser(user.id, !user?.id); + const { assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments } = useAssignments({ assignees: user?.id }); + const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user.id }); - const {users: teachers} = useUsers(userHashTeacher); - const {users: corporates} = useUsers(userHashCorporate); + const { users: teachers } = useUsers(userHashTeacher); + const { users: corporates } = useUsers(userHashCorporate); const users = useMemo(() => [...teachers, ...corporates], [teachers, corporates]); const router = useRouter(); @@ -235,7 +234,6 @@ export default function StudentDashboard({user, linkedCorporate}: Props) {
{capitalize(module)} - {module === "level" && !!gradingSystem && `English Level: ${getGradingLabel(level, gradingSystem.steps)}`} {module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
diff --git a/src/exams/Finish.tsx b/src/exams/Finish.tsx index eb0c333b..398ff06c 100644 --- a/src/exams/Finish.tsx +++ b/src/exams/Finish.tsx @@ -8,7 +8,7 @@ import { calculateBandScore, getGradingLabel } from "@/utils/score"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/router"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { BsArrowCounterclockwise, BsBan, @@ -59,7 +59,9 @@ export default function Finish({ user, scores, modules, information, solutions, const aiUsage = Math.round(ai_usage(solutions) * 100); const exams = useExamStore((state) => state.exams); - const { gradingSystem } = useGradingSystem(); + + const entity = useMemo(() => assignment?.entity || user.entities[0]?.id || "", [assignment?.entity, user.entities]) + const { gradingSystem } = useGradingSystem(entity); const router = useRouter() diff --git a/src/hooks/useGrading.tsx b/src/hooks/useGrading.tsx index caee956d..8dc62296 100644 --- a/src/hooks/useGrading.tsx +++ b/src/hooks/useGrading.tsx @@ -1,9 +1,9 @@ -import {Grading} from "@/interfaces"; -import {Code, Group, User} from "@/interfaces/user"; +import { Grading } from "@/interfaces"; +import { Code, Group, User } from "@/interfaces/user"; import axios from "axios"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; -export default function useGradingSystem() { +export default function useGradingSystem(entity: string) { const [gradingSystem, setGradingSystem] = useState(); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); @@ -11,12 +11,12 @@ export default function useGradingSystem() { const getData = () => { setIsLoading(true); axios - .get(`/api/grading`) + .get(`/api/grading?entity=${entity}`) .then((response) => setGradingSystem(response.data)) .finally(() => setIsLoading(false)); }; - useEffect(getData, []); + useEffect(getData, [entity]); - return {gradingSystem, isLoading, isError, reload: getData, mutate: setGradingSystem}; + return { gradingSystem, isLoading, isError, reload: getData, mutate: setGradingSystem }; } diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 5bbe259a..79248c34 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -7,7 +7,6 @@ export interface Step { } export interface Grading { - user: string; - entity?: string; + entity: string; steps: Step[]; } diff --git a/src/pages/(admin)/CorporateGradingSystem.tsx b/src/pages/(admin)/CorporateGradingSystem.tsx index 014fe11f..a3afe0bb 100644 --- a/src/pages/(admin)/CorporateGradingSystem.tsx +++ b/src/pages/(admin)/CorporateGradingSystem.tsx @@ -1,12 +1,16 @@ import Button from "@/components/Low/Button"; import Input from "@/components/Low/Input"; -import {Grading, Step} from "@/interfaces"; -import {User} from "@/interfaces/user"; -import {CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS} from "@/resources/grading"; +import Select from "@/components/Low/Select"; +import { Grading, Step } from "@/interfaces"; +import { Entity } from "@/interfaces/entity"; +import { User } from "@/interfaces/user"; +import { CEFR_STEPS, GENERAL_STEPS, IELTS_STEPS, TOFEL_STEPS } from "@/resources/grading"; +import { checkAccess } from "@/utils/permissions"; import axios from "axios"; -import {useEffect, useState} from "react"; -import {BsPlusCircle, BsTrash} from "react-icons/bs"; -import {toast} from "react-toastify"; +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { BsPlusCircle, BsTrash } from "react-icons/bs"; +import { toast } from "react-toastify"; const areStepsOverlapped = (steps: Step[]) => { for (let i = 0; i < steps.length; i++) { @@ -21,9 +25,24 @@ const areStepsOverlapped = (steps: Step[]) => { return false; }; -export default function CorporateGradingSystem({user, defaultSteps, mutate}: {user: User; defaultSteps: Step[]; mutate: (steps: Step[]) => void}) { +interface Props { + user: User; + entitiesGrading: Grading[]; + entities: Entity[] + mutate: () => void +} + +export default function CorporateGradingSystem({ user, entitiesGrading = [], entities = [], mutate }: Props) { + const [entity, setEntity] = useState(entitiesGrading[0]?.entity || undefined) const [isLoading, setIsLoading] = useState(false); - const [steps, setSteps] = useState(defaultSteps || []); + const [steps, setSteps] = useState([]); + + useEffect(() => { + if (entity) { + const entitySteps = entitiesGrading.find(e => e.entity === entity)!.steps + setSteps(entitySteps || []) + } + }, [entitiesGrading, entity]) const saveGradingSystem = () => { if (!steps.every((x) => x.min < x.max)) return toast.error("One of your steps has a minimum threshold inferior to its superior threshold."); @@ -37,9 +56,9 @@ export default function CorporateGradingSystem({user, defaultSteps, mutate}: {us setIsLoading(true); axios - .post("/api/grading", {user: user.id, steps}) + .post("/api/grading", { user: user.id, entity, steps }) .then(() => toast.success("Your grading system has been saved!")) - .then(() => mutate(steps)) + .then(mutate) .catch(() => toast.error("Something went wrong, please try again later")) .finally(() => setIsLoading(false)); }; @@ -47,6 +66,15 @@ export default function CorporateGradingSystem({user, defaultSteps, mutate}: {us return (
+
+ + setSteps((prev) => prev.map((x, i) => (i === index ? {...x, label: e} : x)))} + onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, label: e } : x)))} name="min" /> setSteps((prev) => prev.map((x, i) => (i === index ? {...x, max: parseInt(e)} : x)))} + onChange={(e) => setSteps((prev) => prev.map((x, i) => (i === index ? { ...x, max: parseInt(e) } : x)))} name="max" />
@@ -110,7 +138,7 @@ export default function CorporateGradingSystem({user, defaultSteps, mutate}: {us className="w-full flex items-center justify-center" disabled={isLoading} onClick={() => { - const item = {min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: ""}; + const item = { min: steps[index === 0 ? 0 : index - 1].max + 1, max: steps[index + 1].min - 1, label: "" }; setSteps((prev) => [...prev.slice(0, index + 1), item, ...prev.slice(index + 1, steps.length)]); }}> diff --git a/src/pages/api/assignments/statistical/excel.ts b/src/pages/api/assignments/statistical/excel.ts deleted file mode 100644 index b0000814..00000000 --- a/src/pages/api/assignments/statistical/excel.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type {NextApiRequest, NextApiResponse} from "next"; -import {storage} from "@/firebase"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {ref, uploadBytes, getDownloadURL} from "firebase/storage"; -import {AssignmentWithCorporateId} from "@/interfaces/results"; -import moment from "moment-timezone"; -import ExcelJS from "exceljs"; -import {getSpecificUsers} from "@/utils/users.be"; -import {checkAccess} from "@/utils/permissions"; -import {getAssignmentsForCorporates} from "@/utils/assignments.be"; -import {search} from "@/utils/search"; -import {getGradingSystem} from "@/utils/grading.be"; -import {StudentUser, User} from "@/interfaces/user"; -import {calculateBandScore, getGradingLabel} from "@/utils/score"; -import {Module} from "@/interfaces"; -import {uniq} from "lodash"; -import {getUserName} from "@/utils/users"; -import {LevelExam} from "@/interfaces/exam"; -import {getSpecificExams} from "@/utils/exams.be"; - -export default withIronSessionApiRoute(handler, sessionOptions); - -interface TableData { - user: string; - studentID: string; - passportID: string; - exams: string; - email: string; - correct: number; - corporate: string; - submitted: boolean; - date: moment.Moment; - assignment: string; - corporateId: string; - score: number; - level: string; - part1?: string; - part2?: string; - part3?: string; - part4?: string; - part5?: string; -} - -async function handler(req: NextApiRequest, res: NextApiResponse) { - // if (req.method === "GET") return get(req, res); - if (req.method === "POST") return await post(req, res); -} - -const searchFilters = [["email"], ["user"], ["userId"], ["assignment"], ["exams"]]; - -async function post(req: NextApiRequest, res: NextApiResponse) { - // verify if it's a logged user that is trying to export - if (req.session.user) { - if (!checkAccess(req.session.user, ["mastercorporate", "corporate", "developer", "admin"])) { - return res.status(403).json({error: "Unauthorized"}); - } - const { - ids, - startDate, - endDate, - searchText, - displaySelection = true, - } = req.body as { - ids: string[]; - startDate?: string; - endDate?: string; - searchText: string; - displaySelection?: boolean; - }; - const startDateParsed = startDate ? new Date(startDate) : undefined; - const endDateParsed = endDate ? new Date(endDate) : undefined; - const assignments = await getAssignmentsForCorporates(req.session.user.type, ids, startDateParsed, endDateParsed); - - const assignmentUsers = uniq([...assignments.flatMap((x) => x.assignees), ...assignments.flatMap((x) => x.assigner)]); - const assigners = [...new Set(assignments.map((a) => a.assigner))]; - const users = await getSpecificUsers(assignmentUsers); - const assignerUsers = await getSpecificUsers(assigners); - const exams = await getSpecificExams(uniq(assignments.flatMap((x) => x.exams.map((x) => x.id)))); - - const assignerUsersGradingSystems = await Promise.all( - assignerUsers.map(async (user: User) => { - const data = await getGradingSystem(user); - // in this context I need to override as I'll have to match to the assigner - return {...data, user: user.id}; - }), - ); - - const getGradingSystemHelper = ( - exams: {id: string; module: Module; assignee: string}[], - assigner: string, - user: User, - correct: number, - total: number, - ) => { - if (exams.some((e) => e.module === "level")) { - const gradingSystem = assignerUsersGradingSystems.find((gs) => gs.user === assigner); - if (gradingSystem) { - const bandScore = calculateBandScore(correct, total, "level", user?.focus || "academic"); - return { - label: getGradingLabel(bandScore, gradingSystem.steps || []), - score: bandScore, - }; - } - } - - return {score: -1, label: "N/A"}; - }; - - const tableResults = assignments - .reduce((accmA: TableData[], a: AssignmentWithCorporateId) => { - const userResults = a.assignees.map((assignee) => { - const userStats = a.results.find((r) => r.user === assignee)?.stats || []; - const userData = users.find((u) => u.id === assignee); - const corporateUser = users.find((u) => u.id === a.assigner); - const correct = userStats.reduce((n, e) => n + e.score.correct, 0); - const total = userStats.reduce((n, e) => n + e.score.total, 0); - const {label: level, score} = getGradingSystemHelper(a.exams, a.assigner, userData!, correct, total); - - const commonData = { - user: userData?.name || "", - email: userData?.email || "", - studentID: (userData as StudentUser)?.studentID || "", - passportID: (userData as StudentUser)?.demographicInformation?.passport_id || "", - userId: assignee, - exams: a.exams.map((x) => x.id).join(", "), - corporateId: a.corporateId, - corporate: !corporateUser ? "" : getUserName(corporateUser), - assignment: a.name, - level, - score, - }; - if (userStats.length === 0) { - return { - ...commonData, - correct: 0, - submitted: false, - // date: moment(), - }; - } - - let data: {total: number; correct: number}[] = []; - if (a.exams.every((x) => x.module === "level")) { - const exam = exams.find((x) => x.id === a.exams.find((x) => x.assignee === assignee)?.id) as LevelExam; - data = exam.parts.map((x) => { - const exerciseIDs = x.exercises.map((x) => x.id); - const stats = userStats.filter((x) => exerciseIDs.includes(x.exercise)); - - const total = stats.reduce((acc, curr) => acc + curr.score.total, 0); - const correct = stats.reduce((acc, curr) => acc + curr.score.correct, 0); - - return {total, correct}; - }); - } - - const partsData = - data.length > 0 ? data.reduce((acc, e, index) => ({...acc, [`part${index}`]: `${e.correct}/${e.total}`}), {}) : {}; - - return { - ...commonData, - correct, - submitted: true, - date: moment.max(userStats.map((e) => moment(e.date))), - ...partsData, - }; - }) as TableData[]; - - return [...accmA, ...userResults]; - }, []) - .sort((a, b) => b.score - a.score); - - // Create a new workbook and add a worksheet - const workbook = new ExcelJS.Workbook(); - const worksheet = workbook.addWorksheet("Master Statistical"); - - const headers = [ - { - label: "User", - value: (entry: TableData) => entry.user, - }, - { - label: "Email", - value: (entry: TableData) => entry.email, - }, - { - label: "Student ID", - value: (entry: TableData) => entry.studentID, - }, - { - label: "Passport ID", - value: (entry: TableData) => entry.passportID, - }, - ...(displaySelection - ? [ - { - label: "Corporate", - value: (entry: TableData) => entry.corporate, - }, - ] - : []), - { - label: "Assignment", - value: (entry: TableData) => entry.assignment, - }, - { - label: "Submitted", - value: (entry: TableData) => (entry.submitted ? "Yes" : "No"), - }, - { - label: "Score", - value: (entry: TableData) => entry.correct, - }, - { - label: "Date", - value: (entry: TableData) => entry.date?.format("YYYY/MM/DD") || "", - }, - { - label: "Level", - value: (entry: TableData) => entry.level, - }, - ...new Array(5).fill(0).map((_, index) => ({ - label: `Part ${index + 1}`, - value: (entry: TableData) => { - const key = `part${index}` as keyof TableData; - return entry[key] || ""; - }, - })), - ]; - - const filteredSearch = !!searchText ? search(searchText, searchFilters, tableResults) : tableResults; - - worksheet.addRow(headers.map((h) => h.label)); - (filteredSearch as TableData[]).forEach((entry) => { - worksheet.addRow(headers.map((h) => h.value(entry))); - }); - - // Convert workbook to Buffer (Node.js) or Blob (Browser) - const buffer = await workbook.xlsx.writeBuffer(); - - // generate the file ref for storage - const fileName = `${Date.now().toString()}.xlsx`; - const refName = `statistical/${fileName}`; - const fileRef = ref(storage, refName); - // upload the pdf to storage - await uploadBytes(fileRef, buffer, { - contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - }); - - const url = await getDownloadURL(fileRef); - res.status(200).end(url); - return; - } - - return res.status(401).json({error: "Unauthorized"}); -} diff --git a/src/pages/api/grading/index.ts b/src/pages/api/grading/index.ts index 691b25af..931fd26f 100644 --- a/src/pages/api/grading/index.ts +++ b/src/pages/api/grading/index.ts @@ -1,21 +1,21 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type {NextApiRequest, NextApiResponse} from "next"; -import {app} from "@/firebase"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {CorporateUser, Group} from "@/interfaces/user"; -import {Discount, Package} from "@/interfaces/paypal"; -import {v4} from "uuid"; -import {checkAccess} from "@/utils/permissions"; -import {CEFR_STEPS} from "@/resources/grading"; -import {getCorporateUser} from "@/resources/user"; -import {getUserCorporate} from "@/utils/groups.be"; -import {Grading} from "@/interfaces"; -import {getGroupsForUser} from "@/utils/groups.be"; -import {uniq} from "lodash"; -import {getSpecificUsers, getUser} from "@/utils/users.be"; -import {getGradingSystem} from "@/utils/grading.be"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { CorporateUser, Group } from "@/interfaces/user"; +import { Discount, Package } from "@/interfaces/paypal"; +import { v4 } from "uuid"; +import { checkAccess } from "@/utils/permissions"; +import { CEFR_STEPS } from "@/resources/grading"; +import { getCorporateUser } from "@/resources/user"; +import { getUserCorporate } from "@/utils/groups.be"; +import { Grading } from "@/interfaces"; +import { getGroupsForUser } from "@/utils/groups.be"; +import { uniq } from "lodash"; +import { getSpecificUsers, getUser } from "@/utils/users.be"; import client from "@/lib/mongodb"; +import { getGradingSystemByEntity } from "@/utils/grading.be"; const db = client.db(process.env.MONGODB_DB); @@ -28,25 +28,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ok: false}); + res.status(401).json({ ok: false }); return; } - const gradingSystem = await getGradingSystem(req.session.user); + const entity = req.query.entity as string + const gradingSystem = await getGradingSystemByEntity(entity); return res.status(200).json(gradingSystem); } -async function updateGrading(id: string, body: Grading) { - if (await db.collection("grading").findOne({id})) { - await db.collection("grading").updateOne({id}, {$set: body}); - } else { - await db.collection("grading").insertOne({id, ...body}); - } -} - async function post(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ok: false}); + res.status(401).json({ ok: false }); return; } @@ -57,17 +50,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { }); const body = req.body as Grading; - await updateGrading(req.session.user.id, body); + await db.collection("grading").updateOne({ entity: body.entity }, { $set: body }, { upsert: true }); - if (req.session.user.type === "mastercorporate") { - const groups = await getGroupsForUser(req.session.user.id); - const participants = uniq(groups.flatMap((x) => x.participants)); - - const participantUsers = await getSpecificUsers(participants); - const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[]; - - await Promise.all(corporateUsers.map(async (g) => await updateGrading(g.id, body))); - } - - res.status(200).json({ok: true}); + res.status(200).json({ ok: true }); } diff --git a/src/pages/api/statistical.ts b/src/pages/api/statistical.ts index 1283b3d9..e538ff11 100644 --- a/src/pages/api/statistical.ts +++ b/src/pages/api/statistical.ts @@ -16,6 +16,8 @@ import { findBy, mapBy } from "@/utils"; import ExcelJS from "exceljs"; import moment from "moment"; import { Session } from "@/hooks/useSessions"; +import { getGradingSystemByEntity } from "@/utils/grading.be"; +import { getGradingLabel } from "@/utils/score"; export default withIronSessionApiRoute(handler, sessionOptions); @@ -81,14 +83,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet("Statistical"); - entityInformations.forEach((e) => addEntityInformationToWorksheet(worksheet, e)) + for (const e of entityInformations) { + await 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 addEntityInformationToWorksheet = async (worksheet: ExcelJS.Worksheet, entityInformation: EntityInformation) => { const data = [ ['Entity', undefined, undefined, entityInformation.entity.label], ['Assignment', undefined, undefined, entityInformation.assignment.name], @@ -108,6 +112,7 @@ const addEntityInformationToWorksheet = (worksheet: ExcelJS.Worksheet, entityInf dataRows.forEach(row => worksheet.mergeCells(`${row.getCell(4).address}:${row.getCell(7).address}`)) worksheet.addRows([[], []]); + const gradingSystem = await getGradingSystemByEntity(entityInformation.entity.id) for (const exam of entityInformation.exams) { const examRow = worksheet.addRow([`${capitalize(exam.module)} Exam`, undefined, exam.id]) @@ -127,7 +132,9 @@ const addEntityInformationToWorksheet = (worksheet: ExcelJS.Worksheet, entityInf "Student ID", "Passport/ID", "Gender", + "Finished at", "Score", + ...(exam.module === "level" ? ["Grade"] : []), ...parts.map((_, i) => `Part ${i + 1}`) ]) header.font = { bold: true, color: { argb: "FFFFFFFF" } } @@ -152,6 +159,11 @@ const addEntityInformationToWorksheet = (worksheet: ExcelJS.Worksheet, entityInf const { total, correct } = calculateScore(item.result.stats) const score = `${correct} / ${total}` + const finishTimestamp = [...item.result.stats].sort((a, b) => b.date - a.date).shift()?.date || -1 + const finishDate = finishTimestamp === -1 ? "N/A" : moment(new Date(finishTimestamp)).format("DD/MM/YYYY HH:mm") + + const grade = getGradingLabel(correct, gradingSystem.steps) + return [ index + 1, item.student.name, @@ -159,7 +171,9 @@ const addEntityInformationToWorksheet = (worksheet: ExcelJS.Worksheet, entityInf item.student.studentID || "N/A", item.student.demographicInformation?.passport_id || "N/A", item.student.demographicInformation?.gender || "N/A", + finishDate, score, + ...(exam.module === "level" ? [grade] : []), ...parts.map((part) => { const exerciseIDs = mapBy(part.exercises, 'id') const { total, correct } = calculateScore(item.result.stats.filter(s => exerciseIDs.includes(s.exercise))) diff --git a/src/pages/record.tsx b/src/pages/record.tsx index f4d29152..9e76e49d 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -73,7 +73,6 @@ export default function History({ user, users, assignments, entities }: Props) { const [filter, setFilter] = useState(); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser(statsUserId || user?.id); - const { gradingSystem } = useGradingSystem(); const setExams = useExamStore((state) => state.setExams); const setShowSolutions = useExamStore((state) => state.setShowSolutions); @@ -175,7 +174,6 @@ export default function History({ user, users, assignments, entities }: Props) { setSelectedTrainingExams={setSelectedTrainingExams} maxTrainingExams={MAX_TRAINING_EXAMS} setExams={setExams} - gradingSystem={gradingSystem?.steps} setShowSolutions={setShowSolutions} setUserSolutions={setUserSolutions} setSelectedModules={setSelectedModules} diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index fa7399b9..208d061d 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -21,7 +21,6 @@ import IconCard from "@/dashboards/IconCard"; import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs"; import UserCreator from "./(admin)/UserCreator"; import CorporateGradingSystem from "./(admin)/CorporateGradingSystem"; -import useGradingSystem from "@/hooks/useGrading"; import { CEFR_STEPS } from "@/resources/grading"; import { User } from "@/interfaces/user"; import { getUserPermissions } from "@/utils/permissions.be"; @@ -33,119 +32,122 @@ import { mapBy, serialize, redirect } from "@/utils"; import { EntityWithRoles } from "@/interfaces/entity"; import { requestUser } from "@/utils/api"; import { isAdmin } from "@/utils/users"; +import { getGradingSystemByEntities, getGradingSystemByEntity } from "@/utils/grading.be"; +import { Grading } from "@/interfaces"; +import { useRouter } from "next/router"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) if (!user) return redirect("/login") - if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) - return redirect("/") + if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) + return redirect("/") - const permissions = await getUserPermissions(user.id); - const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id')) - const allUsers = await getUsers() + const permissions = await getUserPermissions(user.id); + const entities = isAdmin(user) ? await getEntitiesWithRoles() : await getEntitiesWithRoles(mapBy(user.entities, 'id')) + const allUsers = await getUsers() + const gradingSystems = await getGradingSystemByEntities(mapBy(entities, 'id')) + const entitiesGrading = entities.map(e => gradingSystems.find(g => g.entity === e.id) || { entity: e.id, steps: CEFR_STEPS }) - return { - props: serialize({ user, permissions, entities, allUsers }), - }; + return { + props: serialize({ user, permissions, entities, allUsers, entitiesGrading }), + }; }, sessionOptions); interface Props { - user: User; - permissions: PermissionType[]; + user: User; + permissions: PermissionType[]; entities: EntityWithRoles[]; allUsers: User[] + entitiesGrading: Grading[] } -export default function Admin({ user, entities, permissions, allUsers }: Props) { - const { gradingSystem, mutate } = useGradingSystem(); +export default function Admin({ user, entities, permissions, allUsers, entitiesGrading }: Props) { + const [modalOpen, setModalOpen] = useState(); + const router = useRouter() - const [modalOpen, setModalOpen] = useState(); + return ( + <> + + Settings Panel | EnCoach + + + + + + + setModalOpen(undefined)} maxWidth="max-w-[85%]"> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + router.replace(router.asPath)} + /> + - return ( - <> - - Settings Panel | EnCoach - - - - - - - setModalOpen(undefined)} maxWidth="max-w-[85%]"> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - { - mutate({ user: user.id, steps }); - setModalOpen(undefined); - }} - /> - - -
- - {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( -
- setModalOpen("createCode")} - /> - setModalOpen("batchCreateCode")} - /> - setModalOpen("createUser")} - /> - setModalOpen("batchCreateUser")} - /> - {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( - setModalOpen("gradingSystem")} - /> - )} -
- )} -
-
- -
-
- - ); +
+ + {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( +
+ setModalOpen("createCode")} + /> + setModalOpen("batchCreateCode")} + /> + setModalOpen("createUser")} + /> + setModalOpen("batchCreateUser")} + /> + {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( + setModalOpen("gradingSystem")} + /> + )} +
+ )} +
+
+ +
+
+ + ); } diff --git a/src/pages/statistical.tsx b/src/pages/statistical.tsx index 732009d9..91eed6b7 100644 --- a/src/pages/statistical.tsx +++ b/src/pages/statistical.tsx @@ -78,6 +78,7 @@ export default function Statistical({ user, students, entities, assignments, ses const [startDate, setStartDate] = useState(new Date()); const [endDate, setEndDate] = useState(moment().add(1, 'month').toDate()); const [selectedEntities, setSelectedEntities] = useState([]) + const [isDownloading, setIsDownloading] = useState(false) const resetDateRange = () => { const orderedAssignments = orderBy(assignments, ['startDate'], ['asc']) @@ -137,6 +138,8 @@ export default function Statistical({ user, students, entities, assignments, ses }), [data]) const downloadExcel = async () => { + setIsDownloading(true) + const request = await axios.post("/api/statistical", { entities: entities.filter(e => selectedEntities.includes(e.id)), items: data, @@ -156,6 +159,8 @@ export default function Statistical({ user, students, entities, assignments, ses document.body.removeChild(link); URL.revokeObjectURL(href); + + setIsDownloading(false) } const columns = [ @@ -189,17 +194,7 @@ export default function Statistical({ user, students, entities, assignments, ses {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 ( @@ -290,6 +285,7 @@ export default function Statistical({ user, students, entities, assignments, ses searchFields={[["student", "name"], ["student", "email"], ["student", "studentID"], ["exams", "id"], ["assignment", "name"]]} searchPlaceholder="Search by student, assignment or exam..." onDownload={downloadExcel} + isDownloadLoading={isDownloading} /> )} diff --git a/src/utils/grading.be.ts b/src/utils/grading.be.ts index 1c7742f5..cffad61c 100644 --- a/src/utils/grading.be.ts +++ b/src/utils/grading.be.ts @@ -1,25 +1,13 @@ -import {CEFR_STEPS} from "@/resources/grading"; -import {getUserCorporate} from "@/utils/groups.be"; -import {User} from "@/interfaces/user"; -import {Grading} from "@/interfaces"; +import { CEFR_STEPS } from "@/resources/grading"; +import { getUserCorporate } from "@/utils/groups.be"; +import { User } from "@/interfaces/user"; +import { Grading } from "@/interfaces"; import client from "@/lib/mongodb"; const db = client.db(process.env.MONGODB_DB); -export const getGradingSystem = async (user: User): Promise => { - const grading = await db.collection("grading").findOne({id: user.id}); - if (!!grading) return grading; - - if (user.type !== "teacher" && user.type !== "student") return {steps: CEFR_STEPS, user: user.id}; - - const corporate = await getUserCorporate(user.id); - if (!corporate) return {steps: CEFR_STEPS, user: user.id}; - - const corporateSnapshot = await db.collection("grading").findOne({id: corporate.id}); - if (!!corporateSnapshot) return corporateSnapshot; - - return {steps: CEFR_STEPS, user: user.id}; -}; - export const getGradingSystemByEntity = async (id: string) => - (await db.collection("grading").findOne({entity: id})) || {steps: CEFR_STEPS, user: ""}; + (await db.collection("grading").findOne({ entity: id })) || { steps: CEFR_STEPS, entity: "" }; + +export const getGradingSystemByEntities = async (ids: string[]) => + await db.collection("grading").find({ entity: { $in: ids } }).toArray();