From dd2ddc0e5ba53eb16c3a2197b87dcdebc1f2bec9 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 7 Nov 2023 22:30:46 +0000 Subject: [PATCH] Finished up a wizard to create Assignments --- src/components/Low/ProgressBar.tsx | 2 +- src/components/UserCard.tsx | 40 +++- src/dashboards/AssignmentCard.tsx | 4 +- src/dashboards/AssignmentCreator.tsx | 276 +++++++++++++++++++++++++++ src/dashboards/Corporate.tsx | 2 +- src/dashboards/Owner.tsx | 2 +- src/dashboards/Student.tsx | 89 ++++++++- src/dashboards/Teacher.tsx | 43 ++++- src/pages/(admin)/Lists/UserList.tsx | 2 +- src/pages/api/assignments/[id].tsx | 48 +++++ src/pages/api/assignments/index.ts | 5 - 11 files changed, 491 insertions(+), 22 deletions(-) create mode 100644 src/dashboards/AssignmentCreator.tsx create mode 100644 src/pages/api/assignments/[id].tsx diff --git a/src/components/Low/ProgressBar.tsx b/src/components/Low/ProgressBar.tsx index 658772f2..b26b475e 100644 --- a/src/components/Low/ProgressBar.tsx +++ b/src/components/Low/ProgressBar.tsx @@ -33,7 +33,7 @@ export default function ProgressBar({label, percentage, color, useColor = false, style={{width: `${percentage}%`}} className={clsx("absolute transition-all duration-300 ease-in-out top-0 left-0 h-full overflow-hidden", progressColorClass[color])} /> - {label} + {label} ); } diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx index 7feeec4b..fe1a0432 100644 --- a/src/components/UserCard.tsx +++ b/src/components/UserCard.tsx @@ -5,6 +5,7 @@ import {RadioGroup} from "@headlessui/react"; import axios from "axios"; import clsx from "clsx"; import moment from "moment"; +import {Divider} from "primereact/divider"; import {useState} from "react"; import ReactDatePicker from "react-datepicker"; import {BsFileEarmarkText, BsPencil, BsStar} from "react-icons/bs"; @@ -24,12 +25,14 @@ const expirationDateColor = (date: Date) => { if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; }; -const UserCard = ({ - onClose, - onViewStudents, - onViewTeachers, - ...user -}: User & {onClose: (reload?: boolean) => void; onViewStudents?: () => void; onViewTeachers?: () => void}) => { +interface Props { + user: User; + onClose: (reload?: boolean) => void; + onViewStudents?: () => void; + onViewTeachers?: () => void; +} + +const UserCard = ({user, onClose, onViewStudents, onViewTeachers}: Props) => { const [expiryDate, setExpiryDate] = useState(user.subscriptionExpirationDate); const {stats} = useStats(user.id); @@ -218,6 +221,31 @@ const UserCard = ({ + {user.corporateInformation && ( + <> + +
+ null} + placeholder="Enter company name" + defaultValue={user.corporateInformation.companyInformation.name} + disabled + /> + null} + placeholder="Enter amount of users" + defaultValue={user.corporateInformation.companyInformation.userAmount} + disabled + /> +
+ + )}
diff --git a/src/dashboards/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx index f73d3e10..1d5d4792 100644 --- a/src/dashboards/AssignmentCard.tsx +++ b/src/dashboards/AssignmentCard.tsx @@ -43,9 +43,9 @@ export default function AssignmentCard({id, name, assigner, startDate, endDate, />
- {moment(startDate).format("DD/MM/YY, hh:mm")} + {moment(startDate).format("DD/MM/YY, HH:mm")} - - {moment(endDate).format("DD/MM/YY, hh:mm")} + {moment(endDate).format("DD/MM/YY, HH:mm")}
{exams.map(({module}) => ( diff --git a/src/dashboards/AssignmentCreator.tsx b/src/dashboards/AssignmentCreator.tsx new file mode 100644 index 00000000..d69a4479 --- /dev/null +++ b/src/dashboards/AssignmentCreator.tsx @@ -0,0 +1,276 @@ +import Input from "@/components/Low/Input"; +import Modal from "@/components/Modal"; +import {Module} from "@/interfaces"; +import clsx from "clsx"; +import {useState} from "react"; +import {BsBook, BsCheckCircle, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import {generate} from "random-words"; +import {capitalize} from "lodash"; +import useUsers from "@/hooks/useUsers"; +import {Group, User} from "@/interfaces/user"; +import ProgressBar from "@/components/Low/ProgressBar"; +import {calculateAverageLevel} from "@/utils/score"; +import Button from "@/components/Low/Button"; +import ReactDatePicker from "react-datepicker"; +import moment from "moment"; +import axios from "axios"; +import {getExam} from "@/utils/exams"; +import {toast} from "react-toastify"; +import {uuidv4} from "@firebase/util"; +import {Assignment} from "@/interfaces/results"; + +interface Props { + isCreating: boolean; + assigner: string; + users: User[]; + groups: Group[]; + assignment?: Assignment; + cancelCreation: () => void; +} + +export default function AssignmentCreator({isCreating, assignment, assigner, groups, users, cancelCreation}: Props) { + const [selectedModules, setSelectedModules] = useState(assignment?.exams.map((e) => e.module) || []); + const [assignees, setAssignees] = useState(assignment?.assignees || []); + const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize})); + const [isLoading, setIsLoading] = useState(false); + const [startDate, setStartDate] = useState(assignment ? moment(assignment.startDate).toDate() : new Date()); + const [endDate, setEndDate] = useState(assignment ? moment(assignment.endDate).toDate() : moment().add(1, "week").toDate()); + + const toggleModule = (module: Module) => { + const modules = selectedModules.filter((x) => x !== module); + setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); + }; + + const toggleAssignee = (user: User) => { + setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); + }; + + const createAssignment = () => { + setIsLoading(true); + + const examPromises = selectedModules.map(async (module) => getExam(module, false)); + Promise.all(examPromises) + .then((exams) => { + (assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, { + assigner, + assignees, + name, + startDate, + endDate, + results: [], + exams: exams.map((e) => ({module: e?.module, id: e?.id})), + }) + .then(() => { + toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); + cancelCreation(); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + setIsLoading(false); + }); + }; + + const deleteAssignment = () => { + if (assignment) { + setIsLoading(true); + + if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return; + axios + .delete(`api/assignments/${assignment.id}`) + .then(() => { + toast.success(`The assignment "${name}" has been deleted successfully!`); + cancelCreation(); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + } + }; + + return ( + +
+
+
toggleModule("reading")} + className={clsx( + "w-fit relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Reading + {!selectedModules.includes("reading") &&
} + {selectedModules.includes("reading") && } +
+
toggleModule("listening")} + className={clsx( + "w-fit relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Listening + {!selectedModules.includes("listening") &&
} + {selectedModules.includes("listening") && } +
+
toggleModule("writing")} + className={clsx( + "w-fit relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ {!selectedModules.includes("writing") &&
} + {selectedModules.includes("writing") && } + Writing +
+
toggleModule("speaking")} + className={clsx( + "w-fit relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ {!selectedModules.includes("speaking") &&
} + {selectedModules.includes("speaking") && } + Speaking +
+
+ + setName(e)} defaultValue={name} label="Assignment Name" required /> + +
+
+ + moment(date).isAfter(new Date())} + dateFormat="dd/MM/yyyy HH:mm" + selected={startDate} + showTimeSelect + onChange={(date) => setStartDate(date)} + /> +
+
+ + moment(date).isAfter(startDate)} + dateFormat="dd/MM/yyyy HH:mm" + selected={endDate} + showTimeSelect + onChange={(date) => setEndDate(date)} + /> +
+
+ +
+ Assignees ({assignees.length} selected) +
+ {groups.map((g) => ( + + ))} +
+
+ {users.map((user) => ( +
toggleAssignee(user)} + className={clsx( + "p-4 flex flex-col gap-2 rounded-xl border cursor-pointer w-72", + "transition ease-in-out duration-300", + assignees.includes(user.id) ? "border-mti-purple" : "border-mti-gray-platinum", + )} + key={user.id}> + + {user.name} + {user.email} + + + + Groups:{" "} + {groups + .filter((g) => g.participants.includes(user.id)) + .map((g) => g.name) + .join(", ")} + +
+ ))} +
+
+
+ + {assignment && ( + + )} + +
+
+
+ ); +} diff --git a/src/dashboards/Corporate.tsx b/src/dashboards/Corporate.tsx index 4b2c3f29..df74128f 100644 --- a/src/dashboards/Corporate.tsx +++ b/src/dashboards/Corporate.tsx @@ -258,7 +258,7 @@ export default function CorporateDashboard({user}: Props) { selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined } onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined} - {...selectedUser} + user={selectedUser} />
)} diff --git a/src/dashboards/Owner.tsx b/src/dashboards/Owner.tsx index 5699ecb4..14396d81 100644 --- a/src/dashboards/Owner.tsx +++ b/src/dashboards/Owner.tsx @@ -300,7 +300,7 @@ export default function OwnerDashboard({user}: Props) { selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined } onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined} - {...selectedUser} + user={selectedUser} /> )} diff --git a/src/dashboards/Student.tsx b/src/dashboards/Student.tsx index ec1e4562..b0584382 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -1,11 +1,15 @@ +import Button from "@/components/Low/Button"; import ProgressBar from "@/components/Low/ProgressBar"; import ProfileSummary from "@/components/ProfileSummary"; +import useAssignments from "@/hooks/useAssignments"; import useStats from "@/hooks/useStats"; import {User} from "@/interfaces/user"; import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {averageScore, groupBySession} from "@/utils/stats"; +import clsx from "clsx"; import {capitalize} from "lodash"; -import {BsBook, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; +import moment from "moment"; +import {BsArrowRepeat, BsBook, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; interface Props { user: User; @@ -13,6 +17,7 @@ interface Props { export default function StudentDashboard({user}: Props) { const {stats} = useStats(user.id); + const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); return ( <> @@ -44,6 +49,88 @@ export default function StudentDashboard({user}: Props) { +
+
+
+ Assignments + +
+
+ + {assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 && + "Assignments will appear here. It seems that for now there are no assignments for you."} + {assignments + .filter((a) => moment(a.endDate).isSameOrAfter(moment())) + .sort((a, b) => moment(a.startDate).diff(b.startDate)) + .map((assignment) => ( +
+
+

{assignment.name}

+ + {moment(assignment.startDate).format("DD/MM/YY, HH:mm")} + - + {moment(assignment.endDate).format("DD/MM/YY, HH:mm")} + +
+
+
+ {MODULE_ARRAY.map((module) => ( +
e.module).includes("reading") + ? "bg-ielts-reading" + : "bg-mti-black/40"), + module === "listening" && + (assignment.exams.map((e) => e.module).includes("listening") + ? "bg-ielts-listening" + : "bg-mti-black/40"), + module === "writing" && + (assignment.exams.map((e) => e.module).includes("writing") + ? "bg-ielts-writing" + : "bg-mti-black/40"), + module === "speaking" && + (assignment.exams.map((e) => e.module).includes("speaking") + ? "bg-ielts-speaking" + : "bg-mti-black/40"), + )}> + {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } +
+ ))} +
+ {!assignment.results.map((r) => r.user).includes(user.id) && ( + <> +
+ +
+ + + )} +
+
+ ))} +
+
+
Score History
diff --git a/src/dashboards/Teacher.tsx b/src/dashboards/Teacher.tsx index 6e94327f..562cf771 100644 --- a/src/dashboards/Teacher.tsx +++ b/src/dashboards/Teacher.tsx @@ -43,6 +43,7 @@ import AssignmentCard from "./AssignmentCard"; import Button from "@/components/Low/Button"; import clsx from "clsx"; import ProgressBar from "@/components/Low/ProgressBar"; +import AssignmentCreator from "./AssignmentCreator"; interface Props { user: User; @@ -53,6 +54,7 @@ export default function TeacherDashboard({user}: Props) { const [selectedUser, setSelectedUser] = useState(); const [showModal, setShowModal] = useState(false); const [selectedAssignment, setSelectedAssignment] = useState(); + const [isCreatingAssignment, setIsCreatingAssignment] = useState(false); const {stats} = useStats(); const {users, reload} = useUsers(); @@ -148,7 +150,10 @@ export default function TeacherDashboard({user}: Props) { return ( <> - setSelectedAssignment(undefined)} title={selectedAssignment?.name}> + setSelectedAssignment(undefined)} + title={selectedAssignment?.name}>
+ x.admin === user.id || x.participants.includes(user.id))} + users={users.filter( + (x) => + x.type === "student" && + (!!selectedUser + ? groups + .filter((g) => g.admin === selectedUser.id) + .flatMap((g) => g.participants) + .includes(x.id) || false + : groups.flatMap((g) => g.participants).includes(x.id)), + )} + assigner={user.id} + isCreating={isCreatingAssignment} + cancelCreation={() => { + setIsCreatingAssignment(false); + setSelectedAssignment(undefined); + reloadAssignments(); + }} + />
setPage("")} @@ -188,12 +214,21 @@ export default function TeacherDashboard({user}: Props) {

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

-
+
setIsCreatingAssignment(true)} + className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> New Assignment
{assignments.filter(futureFilter).map((a) => ( - setSelectedAssignment(a)} key={a.id} /> + { + setSelectedAssignment(a); + setIsCreatingAssignment(true); + }} + key={a.id} + /> ))}
@@ -299,7 +334,7 @@ export default function TeacherDashboard({user}: Props) { selectedUser.type === "corporate" || selectedUser.type === "teacher" ? () => setPage("students") : undefined } onViewTeachers={selectedUser.type === "corporate" ? () => setPage("teachers") : undefined} - {...selectedUser} + user={selectedUser} />
)} diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index 66dc614b..57b271db 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -443,7 +443,7 @@ export default function UserList({user, filter}: {user: User; filter?: (user: Us setSelectedUser(undefined); if (shouldReload) reload(); }} - {...selectedUser} + user={selectedUser} />
)} diff --git a/src/pages/api/assignments/[id].tsx b/src/pages/api/assignments/[id].tsx new file mode 100644 index 00000000..50236691 --- /dev/null +++ b/src/pages/api/assignments/[id].tsx @@ -0,0 +1,48 @@ +// 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, getDoc, deleteDoc} 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 === "PATCH") return PATCH(req, res); + if (req.method === "DELETE") return DELETE(req, res); + + res.status(404).json({ok: false}); +} + +async function GET(req: NextApiRequest, res: NextApiResponse) { + const {id} = req.query; + + const snapshot = await getDoc(doc(db, "assignments", id as string)); + + res.status(200).json({...snapshot.data(), id: snapshot.id}); +} + +async function DELETE(req: NextApiRequest, res: NextApiResponse) { + const {id} = req.query; + + await deleteDoc(doc(db, "assignments", id as string)); + + res.status(200).json({ok: true}); +} + +async function PATCH(req: NextApiRequest, res: NextApiResponse) { + const {id} = req.query; + + await setDoc(doc(db, "assignments", id as string), {assigner: req.session.user?.id, ...req.body}, {merge: true}); + + res.status(200).json({ok: true}); +} diff --git a/src/pages/api/assignments/index.ts b/src/pages/api/assignments/index.ts index a9b69710..eb59649e 100644 --- a/src/pages/api/assignments/index.ts +++ b/src/pages/api/assignments/index.ts @@ -23,11 +23,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } 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);