From 24982efdc8e1ffebd767ba03a6ffb9f7f854416b Mon Sep 17 00:00:00 2001 From: William Jin Date: Thu, 23 Feb 2023 20:16:23 -0800 Subject: [PATCH] Create parent attendance display #130 (#132) --- .../ParentProfile/ParentProfileView.tsx | 7 +- .../ParentProfile/StudentClasses.module.css | 94 +++++++++++++++++++ .../Profile/ParentProfile/StudentClasses.tsx | 89 ++++++++++++++++++ context/LeagueAPI.ts | 6 ++ lib/database/classes.ts | 48 +++++++++- package-lock.json | 30 ++++-- package.json | 3 +- pages/api/users/[id]/classes/index.ts | 59 ++++++++++++ 8 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 components/Profile/ParentProfile/StudentClasses.module.css create mode 100644 components/Profile/ParentProfile/StudentClasses.tsx create mode 100644 pages/api/users/[id]/classes/index.ts diff --git a/components/Profile/ParentProfile/ParentProfileView.tsx b/components/Profile/ParentProfile/ParentProfileView.tsx index 6737c702..3e3dfb85 100644 --- a/components/Profile/ParentProfile/ParentProfileView.tsx +++ b/components/Profile/ParentProfile/ParentProfileView.tsx @@ -9,6 +9,7 @@ import axios from "axios"; import { ConnectStudentProfile } from "./ConnectStudentProfile"; import { ConnectedStudentDisplay } from "./ConnectedStudentDisplay"; import { AddStudentModal } from "./AddStudentModal"; +import { StudentClasses } from "./StudentClasses"; // component that renders the admin/teacher profile page type ParentProfileViewProps = { @@ -126,7 +127,11 @@ const ParentProfileView: React.FC = ({ otherUser }) => { renderStudentCards[currentStudentIndex] )} -
+
+ +
diff --git a/components/Profile/ParentProfile/StudentClasses.module.css b/components/Profile/ParentProfile/StudentClasses.module.css new file mode 100644 index 00000000..65d552be --- /dev/null +++ b/components/Profile/ParentProfile/StudentClasses.module.css @@ -0,0 +1,94 @@ +.spacer { + height: 30px; +} + +.accordion { + height: 100%; + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-size: 24px; + line-height: 29px; + padding: 1em 1.5em; + overflow-y: auto; + overflow-x: hidden; + color: #000000; +} + +.item { + font-weight: 600; + font-size: 18px; + line-height: 22px; + background: #f3f3f3; +} + +.item + .item { + margin-top: 2em; + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +.button { + cursor: pointer; + padding: 18px; + width: 100%; + text-align: left; + border: none; + outline: none; + border-radius: 8px; + box-shadow: 0px 5px 10px -11px; +} + +.button:hover { + background-color: #ddd; +} + +.button:before { + float: right; + content: "▼"; + height: 10px; + width: 10px; + margin-right: 12px; +} + +.button[aria-expanded="true"]::before, +.button[aria-selected="true"]::before { + margin-top: 12px; + margin-right: 4px; + transform: rotate(180deg); +} + +.panel { + font-size: 15px; + line-height: 18px; + padding: 20px; + animation: fadein 0.35s ease-in; + border-radius: 0px 0px 8px 8px; +} + +.panel a { + color: blue; +} + +/* -------------------------------------------------- */ +/* ---------------- Animation part ------------------ */ +/* -------------------------------------------------- */ + +@keyframes fadein { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.info { + display: inline-block; + vertical-align: super; + margin-bottom: 25px; +} + +.info:last-child { + margin-bottom: 0px; +} diff --git a/components/Profile/ParentProfile/StudentClasses.tsx b/components/Profile/ParentProfile/StudentClasses.tsx new file mode 100644 index 00000000..5fbf4b5b --- /dev/null +++ b/components/Profile/ParentProfile/StudentClasses.tsx @@ -0,0 +1,89 @@ +import { Class } from "../../../models/"; +import { APIContext } from "../../../context/APIContext"; +import styles from "./StudentClasses.module.css"; +import React, { useContext, useEffect, useState } from "react"; +import RRule from "rrule"; +import { DateTime } from "luxon"; +import { + Accordion, + AccordionItem, + AccordionItemHeading, + AccordionItemButton, + AccordionItemPanel, +} from "react-accessible-accordion"; +import AccessTime from "@mui/icons-material/AccessTime"; +import CalendarMonth from "@mui/icons-material/CalendarMonth"; +import School from "@mui/icons-material/School"; + +type StudentClassesProps = { + id: string; +}; + +const StudentClasses: React.FC = ({ id }) => { + const api = useContext(APIContext); + const [classes, setClasses] = useState([]); + + useEffect(() => { + (async () => { + await getClasses(); + })(); + }, [id]); + + const getClasses = async (): Promise => { + if (id) { + const classesByUser = await api.getClassesByUser(id); + setClasses(classesByUser); + } else { + setClasses([]); + } + }; + + return ( + <> + + Classes +
+ {classes.map((Class) => ( + + + + {Class.name}{" "} + {Class.minLevel == Class.maxLevel + ? Class.minLevel + : Class.minLevel + "-" + Class.maxLevel} + + + + {" "} +
+ {DateTime.fromFormat(Class.startTime, "HH:mm:ss.SSSZZ").toFormat("t") + + " - " + + DateTime.fromFormat(Class.endTime, "HH:mm:ss.SSSZZ").toFormat("t")} +
+
+ {" "} +
+ { + // Capitalize the first letter of the sentence + RRule.fromString(Class.rrstring).toText().charAt(0).toUpperCase() + + RRule.fromString(Class.rrstring).toText().slice(1) + + ", start " + + DateTime.fromJSDate(RRule.fromString(Class.rrstring).options.dtstart).toFormat( + "DDD" + ) + } +
+
+ {" "} + +
+
+ ))} + + + ); +}; + +export { StudentClasses }; diff --git a/context/LeagueAPI.ts b/context/LeagueAPI.ts index f01d3add..a438babd 100644 --- a/context/LeagueAPI.ts +++ b/context/LeagueAPI.ts @@ -101,6 +101,12 @@ class LeagueAPI { const res = await this.client.get("api/class", { params: { userId: userId } }); return res.data; } + + async getClassesByUser(userId: string): Promise { + const res = await this.client.get(`api/users/${userId}/classes`); + return res.data; + } + async deleteClassEvent(userId: string): Promise { const res = await this.client.delete(`api/events/class/${userId}`); return res.data; diff --git a/lib/database/classes.ts b/lib/database/classes.ts index 0ba5b798..957952b0 100644 --- a/lib/database/classes.ts +++ b/lib/database/classes.ts @@ -31,6 +31,17 @@ type ClassWithoutTeacherInfo = { endTime: string; language: string; }; +const ClassWithoutTeacherInfoSchema = t.type({ + name: t.string, + eventInformationId: t.string, + minLevel: t.number, + maxLevel: t.number, + rrstring: t.string, + startTime: t.string, + endTime: t.string, + language: t.string, +}); +const ClassWithoutTeacherInfoArraySchema = array(ClassWithoutTeacherInfoSchema); const createClass = async ( eventInformationId: string, @@ -196,4 +207,39 @@ const getAllClasses = async (): Promise => { return classesArray; }; -export { createClass, getClass, updateClass, getAllClasses }; +const getClassesByUser = async (id: string): Promise => { + const query = { + text: + "SELECT cl.min_level, cl.max_level, cl.rrstring, cl.start_time, cl.end_time, cl.language, cl.event_information_id, e.name " + + "FROM ((event_information e INNER JOIN classes cl ON e.id = cl.event_information_id) " + + "INNER JOIN commitments ON commitments.event_information_id = e.id) " + + "WHERE commitments.user_id = $1", + values: [id], + }; + const res = await client.query(query); + let classesWithoutTeacher: TypeOf; + try { + classesWithoutTeacher = await decode(ClassWithoutTeacherInfoArraySchema, res.rows); + } catch (e) { + throw Error("Fields returned incorrectly from database"); + } + + const classesArray: Class[] = []; + classesWithoutTeacher.forEach((singleClass) => { + const currClass = { + name: singleClass.name, + eventInformationId: singleClass.eventInformationId, + minLevel: singleClass.minLevel, + maxLevel: singleClass.maxLevel, + rrstring: singleClass.rrstring, + startTime: singleClass.startTime, + endTime: singleClass.endTime, + language: singleClass.language, + teachers: [], + }; + classesArray.push(currClass); + }); + return classesArray; +}; + +export { createClass, getClass, updateClass, getAllClasses, getClassesByUser }; diff --git a/package-lock.json b/package-lock.json index 2a8159fd..9a8a5e8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "io-ts": "^2.2.16", "io-ts-promise": "^2.0.2", "jest-node-exports-resolver": "^1.1.6", - "luxon": "^2.3.1", + "luxon": "^2.5.2", "moment": "^2.29.1", "next": "^12.1.0", "next-swagger-doc": "^0.3.6", @@ -48,6 +48,7 @@ "pino-pretty": "^9.1.1", "postgres-migrations": "^5.3.0", "react": "17.0.2", + "react-accessible-accordion": "^5.0.0", "react-color": "^2.19.3", "react-datetime-picker": "^3.4.3", "react-dom": "17.0.2", @@ -9681,9 +9682,9 @@ "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, "node_modules/luxon": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", - "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", "engines": { "node": ">=12" } @@ -11186,6 +11187,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-accessible-accordion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-accessible-accordion/-/react-accessible-accordion-5.0.0.tgz", + "integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==", + "peerDependencies": { + "react": "^16.3.2 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.3.3 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-calendar": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-3.9.0.tgz", @@ -20277,9 +20287,9 @@ } }, "luxon": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.4.0.tgz", - "integrity": "sha512-w+NAwWOUL5hO0SgwOHsMBAmZ15SoknmQXhSO0hIbJCAmPKSsGeK8MlmhYh2w6Iib38IxN2M+/ooXWLbeis7GuA==" + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", + "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==" }, "lz-string": { "version": "1.4.4", @@ -21428,6 +21438,12 @@ "object-assign": "^4.1.1" } }, + "react-accessible-accordion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-accessible-accordion/-/react-accessible-accordion-5.0.0.tgz", + "integrity": "sha512-MT2obYpTgLIIfPr9d7hEyvPB5rg8uJcHpgA83JSRlEUHvzH48+8HJPvzSs+nM+XprTugDgLfhozO5qyJpBvYRQ==", + "requires": {} + }, "react-calendar": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-3.9.0.tgz", diff --git a/package.json b/package.json index 2f0245fc..4db8a663 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "io-ts": "^2.2.16", "io-ts-promise": "^2.0.2", "jest-node-exports-resolver": "^1.1.6", - "luxon": "^2.3.1", + "luxon": "^2.5.2", "moment": "^2.29.1", "next": "^12.1.0", "next-swagger-doc": "^0.3.6", @@ -58,6 +58,7 @@ "pino-pretty": "^9.1.1", "postgres-migrations": "^5.3.0", "react": "17.0.2", + "react-accessible-accordion": "^5.0.0", "react-color": "^2.19.3", "react-datetime-picker": "^3.4.3", "react-dom": "17.0.2", diff --git a/pages/api/users/[id]/classes/index.ts b/pages/api/users/[id]/classes/index.ts new file mode 100644 index 00000000..7fefb544 --- /dev/null +++ b/pages/api/users/[id]/classes/index.ts @@ -0,0 +1,59 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; +import { StatusCodes } from "http-status-codes"; +import { getClassesByUser } from "../../../../../lib/database/classes"; +import { withLogging } from "../../../../../middleware/withLogging"; +import { logData, onError } from "../../../../../logger/logger"; + +/** + * @swagger + * /api/users/{id}/classes: + * get: + * description: Get all classes for a single user + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 202: + * description: Found user's classes + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * $ref: '#/components/schemas/Class' + */ + +export const userClassesHandler: NextApiHandler = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (!req.query) { + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json("Internal Server Error"); + } + + const userId = req.query.id as string; + if (!userId) { + return res.status(StatusCodes.BAD_REQUEST).json("No id specified"); + } + + switch (req.method) { + case "GET": + try { + const result = await getClassesByUser(userId); + logData("Classes of one user: ", result); + return res.status(StatusCodes.ACCEPTED).json(result); + } catch (e) { + onError(e); + return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json("Internal Server Error"); + } + + default: + return res.status(StatusCodes.METHOD_NOT_ALLOWED).json("Method not allowed"); + } +}; + +export default withLogging(userClassesHandler);