diff --git a/src/components/Medium/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx index a2465caa..7d0edfcd 100644 --- a/src/components/Medium/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { BsClock, BsXCircle } from 'react-icons/bs'; -import clsx from 'clsx'; -import { Stat, User } from '@/interfaces/user'; -import { Module } from "@/interfaces"; +import React from "react"; +import {BsClock, BsXCircle} from "react-icons/bs"; +import clsx from "clsx"; +import {Stat, User} from "@/interfaces/user"; +import {Module} from "@/interfaces"; import ai_usage from "@/utils/ai.detection"; import { calculateBandScore } from "@/utils/score"; import moment from 'moment'; @@ -17,282 +17,287 @@ import { Exam, UserSolution } from '@/interfaces/exam'; import ModuleBadge from '../ModuleBadge'; const formatTimestamp = (timestamp: string | number) => { - const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp; - const date = moment(time); - const formatter = "YYYY/MM/DD - HH:mm"; - return date.format(formatter); + const time = typeof timestamp === "string" ? parseInt(timestamp) : timestamp; + const date = moment(time); + const formatter = "YYYY/MM/DD - HH:mm"; + return date.format(formatter); }; -const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => { - const scores: { - [key in Module]: { total: number; missing: number; correct: number }; - } = { - reading: { - total: 0, - correct: 0, - missing: 0, - }, - listening: { - total: 0, - correct: 0, - missing: 0, - }, - writing: { - total: 0, - correct: 0, - missing: 0, - }, - speaking: { - total: 0, - correct: 0, - missing: 0, - }, - level: { - total: 0, - correct: 0, - missing: 0, - }, - }; +const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { + const scores: { + [key in Module]: {total: number; missing: number; correct: number}; + } = { + reading: { + total: 0, + correct: 0, + missing: 0, + }, + listening: { + total: 0, + correct: 0, + missing: 0, + }, + writing: { + total: 0, + correct: 0, + missing: 0, + }, + speaking: { + total: 0, + correct: 0, + missing: 0, + }, + level: { + total: 0, + correct: 0, + missing: 0, + }, + }; - stats.forEach((x) => { - scores[x.module!] = { - total: scores[x.module!].total + x.score.total, - correct: scores[x.module!].correct + x.score.correct, - missing: scores[x.module!].missing + x.score.missing, - }; - }); + stats.forEach((x) => { + scores[x.module!] = { + total: scores[x.module!].total + x.score.total, + correct: scores[x.module!].correct + x.score.correct, + missing: scores[x.module!].missing + x.score.missing, + }; + }); - return Object.keys(scores) - .filter((x) => scores[x as Module].total > 0) - .map((x) => ({ module: x as Module, ...scores[x as Module] })); + return Object.keys(scores) + .filter((x) => scores[x as Module].total > 0) + .map((x) => ({module: x as Module, ...scores[x as Module]})); }; interface StatsGridItemProps { - width?: string | undefined; - height?: string | undefined; - examNumber?: number | undefined; - stats: Stat[]; - timestamp: string | number; - user: User, - assignments: Assignment[]; - users: User[]; - training?: boolean, - selectedTrainingExams?: string[]; - maxTrainingExams?: number; - setSelectedTrainingExams?: React.Dispatch>; - setExams: (exams: Exam[]) => void; - setShowSolutions: (show: boolean) => void; - setUserSolutions: (solutions: UserSolution[]) => void; - setSelectedModules: (modules: Module[]) => void; - setInactivity: (inactivity: number) => void; - setTimeSpent: (time: number) => void; - renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode; + width?: string | undefined; + height?: string | undefined; + examNumber?: number | undefined; + stats: Stat[]; + timestamp: string | number; + user: User; + assignments: Assignment[]; + users: User[]; + training?: boolean; + selectedTrainingExams?: string[]; + maxTrainingExams?: number; + setSelectedTrainingExams?: React.Dispatch>; + setExams: (exams: Exam[]) => void; + setShowSolutions: (show: boolean) => void; + setUserSolutions: (solutions: UserSolution[]) => void; + setSelectedModules: (modules: Module[]) => void; + setInactivity: (inactivity: number) => void; + setTimeSpent: (time: number) => void; + renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode; } const StatsGridItem: React.FC = ({ - stats, - timestamp, - user, - assignments, - users, - training, - selectedTrainingExams, - setSelectedTrainingExams, - setExams, - setShowSolutions, - setUserSolutions, - setSelectedModules, - setInactivity, - setTimeSpent, - renderPdfIcon, - width = undefined, - height = undefined, - examNumber = undefined, - maxTrainingExams = undefined + stats, + timestamp, + user, + assignments, + users, + training, + selectedTrainingExams, + setSelectedTrainingExams, + setExams, + setShowSolutions, + setUserSolutions, + setSelectedModules, + setInactivity, + setTimeSpent, + renderPdfIcon, + width = undefined, + height = undefined, + examNumber = undefined, + maxTrainingExams = undefined, }) => { - const router = useRouter(); - const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); - const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); - const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); - const assignmentID = stats.reduce((_, current) => current.assignment as any, ""); - const assignment = assignments.find((a) => a.id === assignmentID); - const isDisabled = stats.some((x) => x.isDisabled); + const router = useRouter(); + const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); + const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); + const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); + const assignmentID = stats.reduce((_, current) => current.assignment as any, ""); + const assignment = assignments.find((a) => a.id === assignmentID); + const isDisabled = stats.some((x) => x.isDisabled); - const aiUsage = Math.round(ai_usage(stats) * 100); + const aiUsage = Math.round(ai_usage(stats) * 100); - const aggregatedLevels = aggregatedScores.map((x) => ({ - module: x.module, - level: calculateBandScore(x.correct, x.total, x.module, user.focus), - })); + const aggregatedLevels = aggregatedScores.map((x) => ({ + module: x.module, + level: calculateBandScore(x.correct, x.total, x.module, user.focus), + })); - const textColor = clsx( - correct / total >= 0.7 && "text-mti-purple", - correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", - correct / total < 0.3 && "text-mti-rose", - ); + const textColor = clsx( + correct / total >= 0.7 && "text-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", + correct / total < 0.3 && "text-mti-rose", + ); - const { timeSpent, inactivity, session } = stats[0]; + const {timeSpent, inactivity, session} = stats[0]; - const selectExam = () => { - if (training && !isDisabled && typeof maxTrainingExams !== "undefined" && typeof setSelectedTrainingExams !== "undefined" && typeof timestamp == "string") { - setSelectedTrainingExams(prevExams => { - const uniqueExams = [...new Set(stats.map(stat => `${stat.module}-${stat.date}`))]; - const indexes = uniqueExams.map(exam => prevExams.indexOf(exam)).filter(index => index !== -1); - if (indexes.length > 0) { - const newExams = [...prevExams]; - indexes.sort((a, b) => b - a).forEach(index => { - newExams.splice(index, 1); - }); - return newExams; - } else { - if (prevExams.length + uniqueExams.length <= maxTrainingExams) { - return [...prevExams, ...uniqueExams]; - } else { - return prevExams; - } - } - }); - } else { - const examPromises = uniqBy(stats, "exam").map((stat) => { - return getExamById(stat.module, stat.exam); - }); + const selectExam = () => { + if ( + training && + !isDisabled && + typeof maxTrainingExams !== "undefined" && + typeof setSelectedTrainingExams !== "undefined" && + typeof timestamp == "string" + ) { + setSelectedTrainingExams((prevExams) => { + const uniqueExams = [...new Set(stats.map((stat) => `${stat.module}-${stat.date}`))]; + const indexes = uniqueExams.map((exam) => prevExams.indexOf(exam)).filter((index) => index !== -1); + if (indexes.length > 0) { + const newExams = [...prevExams]; + indexes + .sort((a, b) => b - a) + .forEach((index) => { + newExams.splice(index, 1); + }); + return newExams; + } else { + if (prevExams.length + uniqueExams.length <= maxTrainingExams) { + return [...prevExams, ...uniqueExams]; + } else { + return prevExams; + } + } + }); + } else { + const examPromises = uniqBy(stats, "exam").map((stat) => { + return getExamById(stat.module, stat.exam); + }); - if (isDisabled) return; + if (isDisabled) return; - Promise.all(examPromises).then((exams) => { - if (exams.every((x) => !!x)) { - if (!!timeSpent) setTimeSpent(timeSpent); - if (!!inactivity) setInactivity(inactivity); - setUserSolutions(convertToUserSolutions(stats)); - setShowSolutions(true); - setExams(exams.map((x) => x!).sort(sortByModule)); - setSelectedModules( - exams - .map((x) => x!) - .sort(sortByModule) - .map((x) => x!.module), - ); - router.push("/exercises"); - } - }); - } - }; + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + if (!!timeSpent) setTimeSpent(timeSpent); + if (!!inactivity) setInactivity(inactivity); + setUserSolutions(convertToUserSolutions(stats)); + setShowSolutions(true); + setExams(exams.map((x) => x!).sort(sortByModule)); + setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + router.push("/exercises"); + } + }); + } + }; - const shouldRenderPDFIcon = () => { - if(assignment) { - return assignment.released; - } + const shouldRenderPDFIcon = () => { + if (assignment) { + return assignment.released; + } - return true; - } - + return true; + }; - const content = ( - <> -
-
- {formatTimestamp(timestamp)} -
- {!!timeSpent && ( - - {Math.floor(timeSpent / 60)} minutes - - )} - {!!inactivity && ( - - {Math.floor(inactivity / 60)} minutes - - )} -
-
-
-
- - Level{" "} - {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} - - {shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)} -
- {examNumber === undefined ? ( - <> - {aiUsage >= 50 && user.type !== "student" && ( -
= 80, - } - )}> - AI Usage -
- )} - - ) : ( -
- {examNumber} -
- )} -
-
+ const content = ( + <> +
+
+ {formatTimestamp(timestamp)} +
+ {!!timeSpent && ( + + {Math.floor(timeSpent / 60)} minutes + + )} + {!!inactivity && ( + + {Math.floor(inactivity / 60)} minutes + + )} +
+
+
+
+ + Level{" "} + {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} + + {shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)} +
+ {examNumber === undefined ? ( + <> + {aiUsage >= 50 && user.type !== "student" && ( +
= 80, + })}> + AI Usage +
+ )} + + ) : ( +
+ {examNumber} +
+ )} +
+
-
-
- {aggregatedLevels.map(({ module, level }) => ( - - ))} -
+
+
+ {aggregatedLevels.map(({module, level}) => ( + + ))} +
- {assignment && ( - - Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name} - - )} -
- - ); + {assignment && ( + + Assignment: {assignment.name}, Teacher: {users.find((u) => u.id === assignment.assigner)?.name} + + )} +
+ + ); - return ( - <> -
= 0.7 && "hover:border-mti-purple", - correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", - correct / total < 0.3 && "hover:border-mti-rose", - typeof selectedTrainingExams !== "undefined" && typeof timestamp === "string" && selectedTrainingExams.some(exam => exam.includes(timestamp)) && "border-2 border-slate-600", - )} - onClick={examNumber === undefined ? selectExam : undefined} - style={{ - ...(width !== undefined && { width }), - ...(height !== undefined && { height }), - }} - data-tip="This exam is still being evaluated..." - role="button"> - {content} -
-
= 0.7 && "hover:border-mti-purple", - correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", - correct / total < 0.3 && "hover:border-mti-rose", - )} - data-tip="Your screen size is too small to view previous exams." - style={{ - ...(width !== undefined && { width }), - ...(height !== undefined && { height }), - }} - role="button"> - {content} -
- - ); + return ( + <> +
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + typeof selectedTrainingExams !== "undefined" && + typeof timestamp === "string" && + selectedTrainingExams.some((exam) => exam.includes(timestamp)) && + "border-2 border-slate-600", + )} + onClick={examNumber === undefined ? selectExam : undefined} + style={{ + ...(width !== undefined && {width}), + ...(height !== undefined && {height}), + }} + data-tip="This exam is still being evaluated..." + role="button"> + {content} +
+
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + data-tip="Your screen size is too small to view previous exams." + style={{ + ...(width !== undefined && {width}), + ...(height !== undefined && {height}), + }} + role="button"> + {content} +
+ + ); }; -export default StatsGridItem; \ No newline at end of file +export default StatsGridItem; diff --git a/src/dashboards/Student.tsx b/src/dashboards/Student.tsx index 070d2d6a..5fd2a862 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -3,6 +3,7 @@ import ProgressBar from "@/components/Low/ProgressBar"; import InviteCard from "@/components/Medium/InviteCard"; import ProfileSummary from "@/components/ProfileSummary"; import useAssignments from "@/hooks/useAssignments"; +import useGradingSystem from "@/hooks/useGrading"; import useInvites from "@/hooks/useInvites"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import useUsers from "@/hooks/useUsers"; @@ -13,7 +14,7 @@ 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 {getLevelLabel, getLevelScore} from "@/utils/score"; +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"; @@ -34,8 +35,9 @@ interface Props { export default function StudentDashboard({user}: Props) { const [corporateUserToShow, setCorporateUserToShow] = useState(); - const {data: stats} = useFilterRecordsByUser(user.id, !user?.id); const {users} = useUsers(); + const {gradingSystem} = useGradingSystem(); + 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}); @@ -173,10 +175,7 @@ export default function StudentDashboard({user}: Props) {
-
@@ -243,7 +242,7 @@ export default function StudentDashboard({user}: Props) {
{capitalize(module)} - {module === "level" && `English Level: ${getLevelLabel(level).join(" / ")}`} + {module === "level" && !!gradingSystem && `English Level: ${getGradingLabel(level, gradingSystem.steps)}`} {module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`}
@@ -252,9 +251,9 @@ export default function StudentDashboard({user}: Props) { diff --git a/src/exams/Finish.tsx b/src/exams/Finish.tsx index e4d5bc30..7ce9396c 100644 --- a/src/exams/Finish.tsx +++ b/src/exams/Finish.tsx @@ -4,7 +4,7 @@ import {moduleResultText} from "@/constants/ielts"; import {Module} from "@/interfaces"; import {User} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import {calculateBandScore} from "@/utils/score"; +import {calculateBandScore, getGradingLabel} from "@/utils/score"; import clsx from "clsx"; import Link from "next/link"; import {useRouter} from "next/router"; @@ -24,8 +24,9 @@ import {LevelScore} from "@/constants/ielts"; import {getLevelScore} from "@/utils/score"; import {capitalize} from "lodash"; import Modal from "@/components/Modal"; -import { UserSolution } from "@/interfaces/exam"; +import {UserSolution} from "@/interfaces/exam"; import ai_usage from "@/utils/ai.detection"; +import useGradingSystem from "@/hooks/useGrading"; interface Score { module: Module; @@ -53,8 +54,8 @@ export default function Finish({user, scores, modules, information, solutions, i const [isExtraInformationOpen, setIsExtraInformationOpen] = useState(false); const aiUsage = Math.round(ai_usage(solutions) * 100); - const exams = useExamStore((state) => state.exams); + const {gradingSystem} = useGradingSystem(); useEffect(() => setSelectedScore(scores.find((x) => x.module === selectedModule)!), [scores, selectedModule]); @@ -94,10 +95,10 @@ export default function Finish({user, scores, modules, information, solutions, i const showLevel = (level: number) => { if (selectedModule === "level") { - const [levelStr, grade] = getLevelScore(level); + const label = getGradingLabel(level, gradingSystem?.steps || []); return (
- {levelStr} + {label}
); } @@ -155,26 +156,24 @@ export default function Finish({user, scores, modules, information, solutions, i )} {modules.includes("writing") && (
-
setSelectedModule("writing")} - className={clsx( - "hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", - selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing", - )}> - - Writing -
- {aiUsage >= 50 && user.type !== "student" && ( -
= 80, - } - )}> - AI Usage +
setSelectedModule("writing")} + className={clsx( + "hover:bg-ielts-writing flex cursor-pointer items-center gap-2 rounded-xl p-4 transition duration-300 ease-in-out hover:text-white hover:shadow-lg", + selectedModule === "writing" ? "bg-ielts-writing text-white" : "bg-mti-gray-smoke text-ielts-writing", + )}> + + Writing
- )} + {aiUsage >= 50 && user.type !== "student" && ( +
= 80, + })}> + AI Usage +
+ )}
)} {modules.includes("speaking") && ( diff --git a/src/hooks/useGrading.tsx b/src/hooks/useGrading.tsx new file mode 100644 index 00000000..caee956d --- /dev/null +++ b/src/hooks/useGrading.tsx @@ -0,0 +1,22 @@ +import {Grading} from "@/interfaces"; +import {Code, Group, User} from "@/interfaces/user"; +import axios from "axios"; +import {useEffect, useState} from "react"; + +export default function useGradingSystem() { + const [gradingSystem, setGradingSystem] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get(`/api/grading`) + .then((response) => setGradingSystem(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, []); + + return {gradingSystem, isLoading, isError, reload: getData, mutate: setGradingSystem}; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index e43f8467..cd740bd4 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -1 +1,12 @@ export type Module = "reading" | "listening" | "writing" | "speaking" | "level"; + +export interface Step { + min: number; + max: number; + label: string; +} + +export interface Grading { + user: string; + steps: Step[]; +} diff --git a/src/pages/(admin)/BatchCreateUser.tsx b/src/pages/(admin)/BatchCreateUser.tsx index a1d9b856..4453bead 100644 --- a/src/pages/(admin)/BatchCreateUser.tsx +++ b/src/pages/(admin)/BatchCreateUser.tsx @@ -104,7 +104,7 @@ export default function BatchCreateUser({user}: {user: User}) { const information = uniqBy( rows .map((row) => { - const [firstName, lastName, country, passport_id, email, phone, group, studentID, corporate] = row as string[]; + const [firstName, lastName, studentID, passport_id, email, phone, corporate, group, country] = row as string[]; const countryItem = countryCodes.findOne("countryCode" as any, country.toUpperCase()) || countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase()); @@ -179,13 +179,13 @@ export default function BatchCreateUser({user}: {user: User}) { First Name Last Name - Country + Student ID Passport/National ID E-mail Phone Number - Group Name - Student ID {user?.type !== "corporate" && Corporate (e-mail)} + Group Name + Country diff --git a/src/pages/(admin)/CorporateGradingSystem.tsx b/src/pages/(admin)/CorporateGradingSystem.tsx new file mode 100644 index 00000000..54a8f76b --- /dev/null +++ b/src/pages/(admin)/CorporateGradingSystem.tsx @@ -0,0 +1,128 @@ +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 axios from "axios"; +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++) { + if (i === 0) continue; + + const step = steps[i]; + const previous = steps[i - 1]; + + if (previous.max >= step.min) return true; + } + + return false; +}; + +export default function CorporateGradingSystem({user, defaultSteps, mutate}: {user: User; defaultSteps: Step[]; mutate: (steps: Step[]) => void}) { + const [isLoading, setIsLoading] = useState(false); + const [steps, setSteps] = useState(defaultSteps || []); + + 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."); + if (areStepsOverlapped(steps)) return toast.error("There seems to be an overlap in one of your steps."); + if ( + steps.reduce((acc, curr) => { + console.log(acc - (curr.max - curr.min + 1)); + return acc - (curr.max - curr.min + 1); + }, 100) > 0 + ) + return toast.error("There seems to be an open interval in your steps."); + + setIsLoading(true); + axios + .post("/api/grading", {user: user.id, steps}) + .then(() => toast.success("Your grading system has been saved!")) + .then(() => mutate(steps)) + .catch(() => toast.error("Something went wrong, please try again later")) + .finally(() => setIsLoading(false)); + }; + + return ( +
+ + + +
+ + + + +
+ + {steps.map((step, index) => ( + <> +
+
+ setSteps((prev) => prev.map((x, i) => (i === index ? {...x, min: parseInt(e)} : x)))} + name="min" + /> + 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)))} + name="max" + /> +
+ {index !== 0 && index !== steps.length - 1 && ( + + )} +
+ + {index < steps.length - 1 && ( + + )} + + ))} + + +
+ ); +} diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index b834f98f..7b0169e0 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ -import { Module } from "@/interfaces"; -import { useEffect, useState } from "react"; +import {Module} from "@/interfaces"; +import {useEffect, useState} from "react"; import AbandonPopup from "@/components/AbandonPopup"; import Layout from "@/components/High/Layout"; @@ -12,24 +12,25 @@ import Selection from "@/exams/Selection"; import Speaking from "@/exams/Speaking"; import Writing from "@/exams/Writing"; import useUser from "@/hooks/useUser"; -import { Exam, LevelExam, UserSolution, Variant } from "@/interfaces/exam"; -import { Stat } from "@/interfaces/user"; +import {Exam, LevelExam, UserSolution, Variant} from "@/interfaces/exam"; +import {Stat} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import { evaluateSpeakingAnswer, evaluateWritingAnswer } from "@/utils/evaluation"; -import { defaultExamUserSolutions, getExam } from "@/utils/exams"; +import {evaluateSpeakingAnswer, evaluateWritingAnswer} from "@/utils/evaluation"; +import {defaultExamUserSolutions, getExam} from "@/utils/exams"; import axios from "axios"; -import { useRouter } from "next/router"; -import { toast, ToastContainer } from "react-toastify"; -import { v4 as uuidv4 } from "uuid"; +import {useRouter} from "next/router"; +import {toast, ToastContainer} from "react-toastify"; +import {v4 as uuidv4} from "uuid"; import useSessions from "@/hooks/useSessions"; import ShortUniqueId from "short-unique-id"; import clsx from "clsx"; +import useGradingSystem from "@/hooks/useGrading"; interface Props { page: "exams" | "exercises"; } -export default function ExamPage({ page }: Props) { +export default function ExamPage({page}: Props) { const [variant, setVariant] = useState("full"); const [avoidRepeated, setAvoidRepeated] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false); @@ -44,21 +45,21 @@ export default function ExamPage({ page }: Props) { const assignment = useExamStore((state) => state.assignment); const initialTimeSpent = useExamStore((state) => state.timeSpent); - const { exam, setExam } = useExamStore((state) => state); - const { exams, setExams } = useExamStore((state) => state); - const { sessionId, setSessionId } = useExamStore((state) => state); - const { partIndex, setPartIndex } = useExamStore((state) => state); - const { moduleIndex, setModuleIndex } = useExamStore((state) => state); - const { questionIndex, setQuestionIndex } = useExamStore((state) => state); - const { exerciseIndex, setExerciseIndex } = useExamStore((state) => state); - const { userSolutions, setUserSolutions } = useExamStore((state) => state); - const { showSolutions, setShowSolutions } = useExamStore((state) => state); - const { selectedModules, setSelectedModules } = useExamStore((state) => state); - const { inactivity, setInactivity } = useExamStore((state) => state); - const { bgColor, setBgColor } = useExamStore((state) => state); - const setShuffleMaps = useExamStore((state) => state.setShuffles) + const {exam, setExam} = useExamStore((state) => state); + const {exams, setExams} = useExamStore((state) => state); + const {sessionId, setSessionId} = useExamStore((state) => state); + const {partIndex, setPartIndex} = useExamStore((state) => state); + const {moduleIndex, setModuleIndex} = useExamStore((state) => state); + const {questionIndex, setQuestionIndex} = useExamStore((state) => state); + const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); + const {userSolutions, setUserSolutions} = useExamStore((state) => state); + const {showSolutions, setShowSolutions} = useExamStore((state) => state); + const {selectedModules, setSelectedModules} = useExamStore((state) => state); + const {inactivity, setInactivity} = useExamStore((state) => state); + const {bgColor, setBgColor} = useExamStore((state) => state); + const setShuffleMaps = useExamStore((state) => state.setShuffles); - const { user } = useUser({ redirectTo: "/login" }); + const {user} = useUser({redirectTo: "/login"}); const router = useRouter(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -205,9 +206,7 @@ export default function ExamPage({ page }: Props) { }); useEffect(() => { - if (showSolutions) { - setModuleIndex(-1); - } + if (showSolutions) setModuleIndex(-1); }, [setModuleIndex, showSolutions]); useEffect(() => { @@ -261,11 +260,11 @@ export default function ExamPage({ page }: Props) { date: new Date().getTime(), isDisabled: solution.isDisabled, shuffleMaps: solution.shuffleMaps, - ...(assignment ? { assignment: assignment.id } : {}), + ...(assignment ? {assignment: assignment.id} : {}), })); axios - .post<{ ok: boolean }>("/api/stats", newStats) + .post<{ok: boolean}>("/api/stats", newStats) .then((response) => setHasBeenUploaded(response.data.ok)) .catch(() => setHasBeenUploaded(false)); } @@ -277,18 +276,13 @@ export default function ExamPage({ page }: Props) { }, [statsAwaitingEvaluation]); useEffect(() => { - if (statsAwaitingEvaluation.length > 0) { - checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation); - } + if (statsAwaitingEvaluation.length > 0) checkIfStatsHaveBeenEvaluated(statsAwaitingEvaluation); // eslint-disable-next-line react-hooks/exhaustive-deps }, [statsAwaitingEvaluation]); useEffect(() => { - - if (exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) { - setBgColor("bg-ielts-level-light"); - } - }, [exam, showSolutions, setBgColor]) + if (exam && exam.module === "level" && exam.parts[0].intro && !showSolutions) setBgColor("bg-ielts-level-light"); + }, [exam, showSolutions, setBgColor]); const checkIfStatsHaveBeenEvaluated = (ids: string[]) => { setTimeout(async () => { @@ -333,7 +327,7 @@ export default function ExamPage({ page }: Props) { ), }), ); - return Object.assign(exam, { parts }); + return Object.assign(exam, {parts}); } const exercises = exam.exercises.map((x) => @@ -341,7 +335,7 @@ export default function ExamPage({ page }: Props) { userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions, }), ); - return Object.assign(exam, { exercises }); + return Object.assign(exam, {exercises}); }; const onFinish = async (solutions: UserSolution[]) => { @@ -396,7 +390,7 @@ export default function ExamPage({ page }: Props) { correct: number; }[] => { const scores: { - [key in Module]: { total: number; missing: number; correct: number }; + [key in Module]: {total: number; missing: number; correct: number}; } = { reading: { total: 0, @@ -438,7 +432,7 @@ export default function ExamPage({ page }: Props) { return Object.keys(scores) .filter((x) => scores[x as Module].total > 0) - .map((x) => ({ module: x as Module, ...scores[x as Module] })); + .map((x) => ({module: x as Module, ...scores[x as Module]})); }; const renderScreen = () => { @@ -472,7 +466,7 @@ export default function ExamPage({ page }: Props) { onViewResults={(index?: number) => { if (exams[0].module === "level") { const levelExam = exams[0] as LevelExam; - const allExercises = levelExam.parts.flatMap(part => part.exercises); + const allExercises = levelExam.parts.flatMap((part) => part.exercises); const exerciseOrderMap = new Map(allExercises.map((ex, index) => [ex.id, index])); const orderedSolutions = userSolutions.slice().sort((a, b) => { const indexA = exerciseOrderMap.get(a.exercise) ?? Infinity; @@ -480,7 +474,6 @@ export default function ExamPage({ page }: Props) { return indexA - indexB; }); setUserSolutions(orderedSolutions); - } else { setUserSolutions(userSolutions); } diff --git a/src/pages/api/grading/index.ts b/src/pages/api/grading/index.ts new file mode 100644 index 00000000..70ccbd08 --- /dev/null +++ b/src/pages/api/grading/index.ts @@ -0,0 +1,75 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs, setDoc, doc, getDoc, deleteDoc, query} from "firebase/firestore"; +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"; +import {Grading} from "@/interfaces"; +import {getGroupsForUser} from "@/utils/groups.be"; +import {uniq} from "lodash"; +import {getUser} from "@/utils/users.be"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") await get(req, res); + if (req.method === "POST") await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const snapshot = await getDoc(doc(db, "grading", req.session.user.id)); + if (snapshot.exists()) return res.status(200).json(snapshot.data()); + + if (req.session.user.type !== "teacher" && req.session.user.type !== "student") + return res.status(200).json({steps: CEFR_STEPS, user: req.session.user.id}); + + const corporate = await getUserCorporate(req.session.user.id); + if (!corporate) return res.status(200).json(CEFR_STEPS); + + const corporateSnapshot = await getDoc(doc(db, "grading", corporate.id)); + if (corporateSnapshot.exists()) return res.status(200).json(snapshot.data()); + + return res.status(200).json({steps: CEFR_STEPS, user: req.session.user.id}); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + if (!checkAccess(req.session.user, ["admin", "developer", "mastercorporate", "corporate"])) + return res.status(403).json({ + ok: false, + reason: "You do not have permission to create a new grading system", + }); + + const body = req.body as Grading; + await setDoc(doc(db, "grading", req.session.user.id), body); + + 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 Promise.all(participants.map(getUser)); + const corporateUsers = participantUsers.filter((x) => x.type === "corporate") as CorporateUser[]; + + await Promise.all(corporateUsers.map(async (g) => await setDoc(doc(db, "grading", g.id), body))); + } + + res.status(200).json({ok: true}); +} diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 9fdd3781..d1918448 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -19,8 +19,11 @@ import usePermissions from "@/hooks/usePermissions"; import {useState} from "react"; import Modal from "@/components/Modal"; import IconCard from "@/dashboards/IconCard"; -import {BsCode, BsCodeSquare, BsPeopleFill, BsPersonFill} from "react-icons/bs"; +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"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -50,6 +53,7 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { export default function Admin() { const {user} = useUser({redirectTo: "/login"}); const {permissions} = usePermissions(user?.id || ""); + const {gradingSystem, mutate} = useGradingSystem(); const [modalOpen, setModalOpen] = useState(); @@ -71,14 +75,21 @@ export default function Admin() { setModalOpen(undefined)}> - + setModalOpen(undefined)}> - + setModalOpen(undefined)}> + setModalOpen(undefined)}> + mutate({user: user.id, steps})} + /> +
@@ -112,6 +123,15 @@ export default function Admin() { className="w-full h-full" onClick={() => setModalOpen("batchCreateUser")} /> + {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( + setModalOpen("gradingSystem")} + /> + )}
)} diff --git a/src/resources/grading.ts b/src/resources/grading.ts new file mode 100644 index 00000000..c802c80f --- /dev/null +++ b/src/resources/grading.ts @@ -0,0 +1,37 @@ +export const CEFR_STEPS = [ + {min: 0, max: 9, label: "Pre A1"}, + {min: 10, max: 19, label: "A1"}, + {min: 20, max: 39, label: "A2"}, + {min: 40, max: 59, label: "B1"}, + {min: 60, max: 74, label: "B2"}, + {min: 75, max: 89, label: "C1"}, + {min: 90, max: 100, label: "C2"}, +]; + +export const GENERAL_STEPS = [ + {min: 0, max: 9, label: "Beginner"}, + {min: 10, max: 19, label: "Elementary"}, + {min: 20, max: 39, label: "Pre-Intermediate"}, + {min: 40, max: 59, label: "Intermediate"}, + {min: 60, max: 74, label: "Upper-Intermediate"}, + {min: 75, max: 89, label: "Advance"}, + {min: 90, max: 100, label: "Professional user"}, +]; + +export const IELTS_STEPS = [ + {min: 0, max: 9, label: "1.0"}, + {min: 10, max: 19, label: "2.0 - 2.5"}, + {min: 20, max: 39, label: "3.0 - 3.5"}, + {min: 40, max: 59, label: "4.0 - 5.0"}, + {min: 60, max: 74, label: "5.5 - 6.5"}, + {min: 75, max: 89, label: "7.0 - 7.0"}, + {min: 90, max: 100, label: "8.5 - 9.0"}, +]; + +export const TOFEL_STEPS = [ + {min: 0, max: 19, label: "0 - 39"}, + {min: 20, max: 39, label: "40 - 56"}, + {min: 40, max: 74, label: "57 - 86"}, + {min: 75, max: 89, label: "87 - 109"}, + {min: 90, max: 100, label: "110 - 120"}, +]; diff --git a/src/utils/score.ts b/src/utils/score.ts index 244e3368..58ac2be5 100644 --- a/src/utils/score.ts +++ b/src/utils/score.ts @@ -1,5 +1,4 @@ -import {Module} from "@/interfaces"; -import {LevelScore} from "@/constants/ielts"; +import {Module, Step} from "@/interfaces"; import {Stat, User} from "@/interfaces/user"; type Type = "academic" | "general"; @@ -135,6 +134,8 @@ export const calculateBandScore = (correct: number, total: number, module: Modul const marking = moduleMarkings[module][type]; const percentage = (correct * 100) / total; + if (module === "level") return percentage; + for (const value of Object.keys(marking) .map((x) => parseFloat(x)) .sort((a, b) => b - a)) { @@ -147,7 +148,11 @@ export const calculateBandScore = (correct: number, total: number, module: Modul }; export const calculateAverageLevel = (levels: {[key in Module]: number}) => { - return Object.keys(levels).reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 5; + return ( + Object.keys(levels) + .filter((x) => x !== "level") + .reduce((accumulator, current) => levels[current as Module] + accumulator, 0) / 4 + ); }; export const getLevelScore = (level: number) => { @@ -180,6 +185,14 @@ export const getLevelLabel = (level: number) => { return ["Proficiency", "C2"]; }; +export const getGradingLabel = (score: number, grading: Step[]) => { + for (const step of grading) { + if (score >= step.min && score <= step.max) return step.label; + } + + return "N/A"; +}; + export const averageLevelCalculator = (users: User[], studentStats: Stat[]) => { const formattedStats = studentStats .map((s) => ({