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)/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/api/grading/index.ts b/src/pages/api/grading/index.ts new file mode 100644 index 00000000..199dbc8b --- /dev/null +++ b/src/pages/api/grading/index.ts @@ -0,0 +1,74 @@ +// 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(CEFR_STEPS); + + 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(CEFR_STEPS); +} + +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..e3b8936b 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(); @@ -79,6 +83,13 @@ export default function Admin() { 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"}, +];