diff --git a/package.json b/package.json index 2f00cea1..5af650df 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "nodemailer-express-handlebars": "^6.1.0", "primeicons": "^6.0.1", "primereact": "^9.2.3", + "random-words": "^2.0.0", "react": "18.2.0", "react-chartjs-2": "^5.2.0", "react-datepicker": "^4.18.0", diff --git a/src/dashboards/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx new file mode 100644 index 00000000..f73d3e10 --- /dev/null +++ b/src/dashboards/AssignmentCard.tsx @@ -0,0 +1,73 @@ +import ProgressBar from "@/components/Low/ProgressBar"; +import useUsers from "@/hooks/useUsers"; +import {Module} from "@/interfaces"; +import {Assignment} from "@/interfaces/results"; +import {Stat} from "@/interfaces/user"; +import {calculateBandScore} from "@/utils/score"; +import clsx from "clsx"; +import moment from "moment"; +import {useState} from "react"; +import {BsBook, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; + +interface Props { + onClick?: () => void; +} + +export default function AssignmentCard({id, name, assigner, startDate, endDate, assignees, results, exams, onClick}: Assignment & Props) { + const {users} = useUsers(); + + const calculateAverageModuleScore = (module: Module) => { + const resultModuleBandScores = results.map((r) => { + const moduleStats = r.stats.filter((s) => s.module === module); + + const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); + const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); + return calculateBandScore(correct, total, module, r.type); + }); + + return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length; + }; + + return ( +
+
+

{name}

+ +
+ + {moment(startDate).format("DD/MM/YY, hh:mm")} + - + {moment(endDate).format("DD/MM/YY, hh:mm")} + +
+ {exams.map(({module}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {calculateAverageModuleScore(module) > -1 && ( + {calculateAverageModuleScore(module).toFixed(1)} + )} +
+ ))} +
+
+ ); +} diff --git a/src/dashboards/Owner.tsx b/src/dashboards/Owner.tsx index ffe11236..5699ecb4 100644 --- a/src/dashboards/Owner.tsx +++ b/src/dashboards/Owner.tsx @@ -46,7 +46,7 @@ export default function OwnerDashboard({user}: Props) { x.type === "student" && (!!selectedUser ? groups - .filter((g) => g.admin === selectedUser.id) + .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .flatMap((g) => g.participants) .includes(x.id) || false : true); @@ -73,7 +73,7 @@ export default function OwnerDashboard({user}: Props) { x.type === "teacher" && (!!selectedUser ? groups - .filter((g) => g.admin === selectedUser.id) + .filter((g) => g.admin === selectedUser.id || g.participants.includes(selectedUser.id)) .flatMap((g) => g.participants) .includes(x.id) || false : true); diff --git a/src/dashboards/Teacher.tsx b/src/dashboards/Teacher.tsx index 06e485dd..6e94327f 100644 --- a/src/dashboards/Teacher.tsx +++ b/src/dashboards/Teacher.tsx @@ -9,9 +9,14 @@ import moment from "moment"; import {useEffect, useState} from "react"; import { BsArrowLeft, + BsArrowRepeat, BsClipboard2Data, BsClipboard2DataFill, + BsClipboard2Heart, + BsClipboard2X, + BsClipboardPulse, BsClock, + BsEnvelopePaper, BsGlobeCentralSouthAsia, BsPaperclip, BsPerson, @@ -20,6 +25,9 @@ import { BsPersonFillGear, BsPersonGear, BsPersonLinesFill, + BsPlus, + BsRepeat, + BsRepeat1, } from "react-icons/bs"; import UserCard from "@/components/UserCard"; import useGroups from "@/hooks/useGroups"; @@ -29,6 +37,12 @@ import {Module} from "@/interfaces"; import {groupByExam} from "@/utils/stats"; import IconCard from "./IconCard"; import GroupList from "@/pages/(admin)/Lists/GroupList"; +import useAssignments from "@/hooks/useAssignments"; +import {Assignment} from "@/interfaces/results"; +import AssignmentCard from "./AssignmentCard"; +import Button from "@/components/Low/Button"; +import clsx from "clsx"; +import ProgressBar from "@/components/Low/ProgressBar"; interface Props { user: User; @@ -38,10 +52,12 @@ export default function TeacherDashboard({user}: Props) { const [page, setPage] = useState(""); const [selectedUser, setSelectedUser] = useState(); const [showModal, setShowModal] = useState(false); + const [selectedAssignment, setSelectedAssignment] = useState(); const {stats} = useStats(); const {users, reload} = useUsers(); const {groups} = useGroups(user.id); + const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assigner: user.id}); useEffect(() => { setShowModal(!!selectedUser && page === ""); @@ -125,6 +141,74 @@ export default function TeacherDashboard({user}: Props) { return calculateAverageLevel(levels); }; + const AssignmentsPage = () => { + const activeFilter = (a: Assignment) => moment(a.endDate).isAfter(moment()) && moment(a.startDate).isBefore(moment()); + const pastFilter = (a: Assignment) => moment(a.endDate).isBefore(moment()); + const futureFilter = (a: Assignment) => moment(a.startDate).isAfter(moment()); + + return ( + <> + setSelectedAssignment(undefined)} title={selectedAssignment?.name}> +
+ +
+
+
+
setPage("")} + className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> + + Back +
+
+ Reload + +
+
+
+

Active Assignments ({assignments.filter(activeFilter).length})

+
+ {assignments.filter(activeFilter).map((a) => ( + setSelectedAssignment(a)} key={a.id} /> + ))} +
+
+
+

Planned Assignments ({assignments.filter(futureFilter).length})

+
+
+ + New Assignment +
+ {assignments.filter(futureFilter).map((a) => ( + setSelectedAssignment(a)} key={a.id} /> + ))} +
+
+
+

Past Assignments ({assignments.filter(pastFilter).length})

+
+ {assignments.filter(pastFilter).map((a) => ( + setSelectedAssignment(a)} key={a.id} /> + ))} +
+
+ + ); + }; + const DefaultDashboard = () => ( <>
@@ -148,6 +232,15 @@ export default function TeacherDashboard({user}: Props) { color="purple" /> setPage("groups")} /> +
setPage("assignments")} + className="bg-white rounded-xl shadow p-4 flex flex-col gap-4 items-center w-96 h-52 justify-center cursor-pointer hover:shadow-xl transition ease-in-out duration-300"> + + + Assignments + {assignments.length} + +
@@ -214,6 +307,7 @@ export default function TeacherDashboard({user}: Props) { {page === "students" && } {page === "groups" && } + {page === "assignments" && } {page === "" && } ); diff --git a/src/hooks/useAssignments.tsx b/src/hooks/useAssignments.tsx new file mode 100644 index 00000000..8e694904 --- /dev/null +++ b/src/hooks/useAssignments.tsx @@ -0,0 +1,32 @@ +import {Assignment} from "@/interfaces/results"; +import axios from "axios"; +import {useEffect, useState} from "react"; + +export default function useAssignments({assigner, assignees}: {assigner?: string; assignees?: string}) { + const [assignments, setAssignments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get("/api/assignments") + .then((response) => { + if (assigner) { + setAssignments(response.data.filter((a) => a.assigner === assigner)); + return; + } + if (assignees) { + setAssignments(response.data.filter((a) => a.assignees.filter((x) => assignees.includes(x)).length > 0)); + return; + } + + setAssignments(response.data); + }) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, [assignees, assigner]); + + return {assignments, isLoading, isError, reload: getData}; +} diff --git a/src/interfaces/results.ts b/src/interfaces/results.ts index 9bb2d5b8..1acadec3 100644 --- a/src/interfaces/results.ts +++ b/src/interfaces/results.ts @@ -1,4 +1,5 @@ import {Module} from "@/interfaces"; +import {Stat} from "./user"; export type UserResults = {[key in Module]: ModuleResult}; @@ -7,3 +8,18 @@ interface ModuleResult { score: number; total: number; } + +export interface Assignment { + id: string; + name: string; + assigner: string; + assignees: string[]; + results: { + user: string; + type: "academic" | "general"; + stats: Stat[]; + }[]; + exams: {id: string; module: Module}[]; + startDate: Date; + endDate: Date; +} diff --git a/src/pages/api/assignments/index.ts b/src/pages/api/assignments/index.ts new file mode 100644 index 00000000..a9b69710 --- /dev/null +++ b/src/pages/api/assignments/index.ts @@ -0,0 +1,46 @@ +// 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, query, where, setDoc, doc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {uuidv4} from "@firebase/util"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + if (req.method === "GET") return GET(req, res); + if (req.method === "POST") return POST(req, res); + + res.status(404).json({ok: false}); +} + +async function GET(req: NextApiRequest, res: NextApiResponse) { + if (req.session.user!.type !== "teacher") { + res.status(403).json({ok: false}); + return; + } + + const q = query(collection(db, "assignments")); + const snapshot = await getDocs(q); + + const docs = snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })); + + res.status(200).json(docs); +} + +async function POST(req: NextApiRequest, res: NextApiResponse) { + await setDoc(doc(db, "assignments", uuidv4()), {assigner: req.session.user?.id, ...req.body}); + + res.status(200).json({ok: true}); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e3a4a3bb..83cdd8d8 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -166,7 +166,7 @@ export default function Home() { {user.type === "student" && } {user.type === "teacher" && } {user.type === "corporate" && } - {user.type === "owner" && } + {user.type === "owner" && } {user.type === "developer" && } )} diff --git a/yarn.lock b/yarn.lock index 78da8384..80f7a18e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4419,6 +4419,13 @@ quick-lru@^5.1.1: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +random-words@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/random-words/-/random-words-2.0.0.tgz#af1e1a75d506b5dec996094ab074bf2196272287" + integrity sha512-uqpnDqFnYrZajgmvgjmBrSZL2V1UA/9bNPGrilo12CmBeBszoff/avElutUlwWxG12gvmCk/8dUhvHefYxzYjw== + dependencies: + seedrandom "^3.0.5" + react-chartjs-2@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz" @@ -4717,6 +4724,11 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + semver@^6.0.0: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"