diff --git a/backend/src/controllers/calendar.ts b/backend/src/controllers/calendar.ts new file mode 100644 index 00000000..1d0a6af5 --- /dev/null +++ b/backend/src/controllers/calendar.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +/** + * Function handlers for calendar route requests + */ +import { RequestHandler } from "express"; +import createHttpError from "http-errors"; + +import EnrollmentModel from "../models/enrollment"; +import SessionModel from "../models/session"; + +import { Calendar, CalendarSlot } from "./types/calendarTypes"; + +/** + * Calendar Body: { + * + * studentId: string; + * programId: string; + * calendar: { + * date: Date; + * hours: number; + * session: string; + * }[] + * + * } + */ + +/** + * Request handler for getting calendar for student in program + * @param req + * @param res + * @param next + */ +export const getCalendar: RequestHandler = async (req, res, next) => { + try { + const studentId = req.params.studentId; + const programId = req.params.programId; + + const enrollment = EnrollmentModel.find({ studentId, programId }); + if (!enrollment) { + throw createHttpError(404, "Enrollment not found"); + } + + // get all sessions with studentId and programId + const sessions = await SessionModel.find({ programId }); + + const calendar: Calendar = { studentId, programId, calendar: [] }; + for (const session of sessions) { + for (const student of session.students) { + if (student.studentId.toString() === studentId) { + let hours = 0; + if (session.marked) { + hours = student.hoursAttended; + } + const date = session.date; + const sessionId = session._id.toString(); + calendar.calendar.push({ date, hours, session: sessionId }); + } + } + } + console.log(calendar); + + return res.status(200).send(calendar); + } catch (error) { + next(error); + } +}; + +/** + * Handler for editing a day in a calendar + * @param req + * @param res + * @param next + */ +export const editCalendar: RequestHandler = async (req, res, next) => { + try { + const studentId = req.params.studentId; + const programId = req.params.programId; + + const enrollment = await EnrollmentModel.findOne({ studentId, programId }); + if (!enrollment) { + throw createHttpError(404, "Enrollment not found"); + } + + const { hours, session } = req.body as CalendarSlot; + + const sessionObject = await SessionModel.findById(session); + + if (!sessionObject) { + throw createHttpError(404, "Session not found"); + } + + if (sessionObject.programId.toString() !== programId) { + throw createHttpError(404, "Incorrect program for session"); + } + + const student = sessionObject.students.find((s) => s.studentId.toString() === studentId); + + if (!student) { + throw createHttpError(404, "Student not in session"); + } + + const prevHoursAttended = student.hoursAttended; + let hoursLeft = enrollment.hoursLeft + prevHoursAttended; + + student.hoursAttended = hours; + hoursLeft -= student.hoursAttended; + enrollment.hoursLeft = hoursLeft > 0 ? hoursLeft : 0; + + await sessionObject.save(); + await enrollment.save(); + + res.status(200).send("Updated"); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/program.ts b/backend/src/controllers/program.ts index 37105ed7..e8fc1358 100644 --- a/backend/src/controllers/program.ts +++ b/backend/src/controllers/program.ts @@ -68,6 +68,12 @@ export const updateProgram: RequestHandler = async (req, res, next) => { { $set: { status: "Waitlisted", dateUpdated: Date.now() } }, ); + // update days of week when changed + await EnrollmentModel.updateMany( + { programId: { $eq: programId }, status: { $eq: "Joined" } }, + { $set: { schedule: programData.daysOfWeek } }, + ); + res.status(200).json(editedProgram); } catch (error) { next(error); diff --git a/backend/src/controllers/session.ts b/backend/src/controllers/session.ts index 74971609..dd58c0ae 100644 --- a/backend/src/controllers/session.ts +++ b/backend/src/controllers/session.ts @@ -266,6 +266,18 @@ export const updateSession: RequestHandler = async (req, res, next) => { return res.status(404).json({ message: "No object in database with provided ID" }); } + programData.students.forEach(async (student: StudentInfo) => { + const enrollment = await EnrollmentModel.findOne({ + studentId: student.studentId, + programId: programData.programId, + }); + if (enrollment) { + const hours = enrollment.hoursLeft - student.hoursAttended; + enrollment.hoursLeft = hours > 0 ? hours : 0; + await enrollment.save(); + } + }); + const absentStudents = programData.students.filter((student: StudentInfo) => !student.attended); const absenceSessions = absentStudents.map((absentStudent) => ({ diff --git a/backend/src/controllers/student.ts b/backend/src/controllers/student.ts index 82990b49..3d18a115 100644 --- a/backend/src/controllers/student.ts +++ b/backend/src/controllers/student.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /** - * Functions that process task route requests. + * Functions that process student route requests. */ import { RequestHandler } from "express"; @@ -9,6 +9,7 @@ import mongoose, { HydratedDocument } from "mongoose"; import EnrollmentModel from "../models/enrollment"; import { Image } from "../models/image"; +import ProgramModel from "../models/program"; import ProgressNoteModel from "../models/progressNote"; import StudentModel from "../models/student"; import { Enrollment } from "../types/enrollment"; @@ -81,6 +82,10 @@ export const editStudent: RequestHandler = async (req, res, next) => { enrollments.map(async (enrollment: Enrollment) => { const enrollmentExists = await EnrollmentModel.findById(enrollment._id); const enrollmentBody = { ...enrollment, studentId: new mongoose.Types.ObjectId(studentId) }; + const program = await ProgramModel.findById({ _id: enrollment.programId }); + if (program?.type === "regular") { + enrollmentBody.schedule = program.daysOfWeek; + } if (!enrollmentExists) { return await createEnrollment(enrollmentBody); } else { @@ -122,6 +127,9 @@ export const getAllStudents: RequestHandler = async (req, res, next) => { // Ensure that documents that are marked admin are not returned to non-admin users if (accountType !== "admin") { students.forEach((student) => { + if (!student.documents) { + return; + } student.documents = student.documents.filter( (doc) => !doc.markedAdmin, ) as typeof student.documents; @@ -150,7 +158,7 @@ export const getStudent: RequestHandler = async (req, res, next) => { } // Ensure that documents that are marked admin are not returned to non-admin users - if (accountType !== "admin") { + if (studentData.documents && accountType !== "admin") { studentData.documents = studentData.documents.filter( (doc) => !doc.markedAdmin, ) as typeof studentData.documents; diff --git a/backend/src/controllers/types/calendarTypes.ts b/backend/src/controllers/types/calendarTypes.ts new file mode 100644 index 00000000..6a9d4b32 --- /dev/null +++ b/backend/src/controllers/types/calendarTypes.ts @@ -0,0 +1,11 @@ +export type CalendarSlot = { + date: Date; + hours: number; + session: string; +}; + +export type Calendar = { + studentId: string; + programId: string; + calendar: CalendarSlot[]; +}; diff --git a/backend/src/models/enrollment.ts b/backend/src/models/enrollment.ts index 0f8a0963..25fc888a 100644 --- a/backend/src/models/enrollment.ts +++ b/backend/src/models/enrollment.ts @@ -19,10 +19,11 @@ const enrollmentSchema = new mongoose.Schema({ schedule: { type: [String], required: true }, sessionTime: { type: { - start_time: { type: String, required: true }, - end_time: { type: String, required: true }, + start_time: { type: String, required: false, default: "00:00" }, + end_time: { type: String, required: false, default: "00:00" }, }, - required: true, + required: false, + default: { start_time: "00:00", end_time: "00:00" }, }, startDate: { type: Date, required: true }, renewalDate: { type: Date, required: true }, diff --git a/backend/src/models/student.ts b/backend/src/models/student.ts index ac19c756..1628011e 100644 --- a/backend/src/models/student.ts +++ b/backend/src/models/student.ts @@ -7,22 +7,22 @@ const studentSchema = new Schema({ student: { lastName: { type: String, required: true }, firstName: { type: String, required: true }, - email: { type: String, required: true }, - phoneNumber: { type: String, required: true }, + email: { type: String, required: false, default: "" }, + phoneNumber: { type: String, required: false, default: "" }, }, emergency: { - lastName: { type: String, required: true }, - firstName: { type: String, required: true }, - email: { type: String, required: true }, - phoneNumber: { type: String, required: true }, + lastName: { type: String, required: false, default: "" }, + firstName: { type: String, required: false, default: "" }, + email: { type: String, required: false, default: "" }, + phoneNumber: { type: String, required: false, default: "" }, }, serviceCoordinator: { - lastName: { type: String, required: true }, - firstName: { type: String, required: true }, - email: { type: String, required: true }, - phoneNumber: { type: String, required: true }, + lastName: { type: String, required: false, default: "" }, + firstName: { type: String, required: false, default: "" }, + email: { type: String, required: false, default: "" }, + phoneNumber: { type: String, required: false, default: "" }, }, enrollments: { @@ -33,18 +33,18 @@ const studentSchema = new Schema({ }, //Address of student - location: { type: String, required: true }, + location: { type: String, required: false, default: "" }, //String list of medications - medication: { type: String, required: true }, + medication: { type: String, required: false, default: "" }, - birthday: { type: Date, required: true }, - intakeDate: { type: Date, required: true }, - tourDate: { type: Date, required: true }, + birthday: { type: Date, required: false, default: null }, + intakeDate: { type: Date, required: false, default: null }, + tourDate: { type: Date, required: false, default: null }, - conservation: { type: Boolean, required: true }, - UCINumber: { type: String, required: true }, - incidentForm: { type: String, required: true }, + conservation: { type: Boolean, required: false, default: false }, + UCINumber: { type: String, required: false, default: "" }, + incidentForm: { type: String, required: false, default: "" }, documents: { type: [ { @@ -53,7 +53,8 @@ const studentSchema = new Schema({ markedAdmin: { type: Boolean, required: true, default: false }, }, ], - required: true, + required: false, + default: [], }, profilePicture: { type: String, ref: "Image", required: false, default: "default" }, @@ -65,7 +66,7 @@ const studentSchema = new Schema({ }, //Will contain list of all dietary restrictions - dietary: { type: [String] }, + dietary: { type: [String], required: false, default: [] }, }); type Student = InferSchemaType; diff --git a/backend/src/routes/api.ts b/backend/src/routes/api.ts index cc7a8942..cc1a9c90 100644 --- a/backend/src/routes/api.ts +++ b/backend/src/routes/api.ts @@ -1,5 +1,6 @@ import express from "express"; +import calendarRouter from "./calendar"; import imageRouter from "./image"; import programRoutes from "./program"; import progressNoteRoutes from "./progressNote"; @@ -15,6 +16,7 @@ router.use("/student", studentRoutes); router.use("/program", programRoutes); router.use("/session", sessionRoutes); router.use("/progressNote", progressNoteRoutes); +router.use("/calendar", calendarRouter); router.use("/image", imageRouter); export default router; diff --git a/backend/src/routes/calendar.ts b/backend/src/routes/calendar.ts new file mode 100644 index 00000000..d9aca799 --- /dev/null +++ b/backend/src/routes/calendar.ts @@ -0,0 +1,20 @@ +/** + * Calendar route requests + */ +import express from "express"; + +import * as CalendarController from "../controllers/calendar"; +import { verifyAuthToken } from "../validators/auth"; +import * as CalendarValidator from "../validators/calendar"; + +const router = express.Router(); + +router.get("/:studentId/:programId", [verifyAuthToken], CalendarController.getCalendar); +router.patch( + "/:studentId/:programId", + [verifyAuthToken], + CalendarValidator.editCalendar, + CalendarController.editCalendar, +); + +export default router; diff --git a/backend/src/routes/student.ts b/backend/src/routes/student.ts index 4f187660..5baa0150 100644 --- a/backend/src/routes/student.ts +++ b/backend/src/routes/student.ts @@ -1,5 +1,5 @@ /** - * Task route requests. + * Student route requests. */ import express from "express"; diff --git a/backend/src/util/student.ts b/backend/src/util/student.ts index c194c02b..7b4e74b0 100644 --- a/backend/src/util/student.ts +++ b/backend/src/util/student.ts @@ -10,11 +10,11 @@ export const programValidatorUtil = async (enrollments: Enrollment[]) => { "status", "hoursLeft", "schedule", - "sessionTime", "startDate", "renewalDate", "authNumber", ]; + enrollments.forEach((enrollment: Enrollment) => { requiredFields.forEach((field) => { if (!enrollment[field as keyof Enrollment]) diff --git a/backend/src/validators/calendar.ts b/backend/src/validators/calendar.ts new file mode 100644 index 00000000..5877d9e8 --- /dev/null +++ b/backend/src/validators/calendar.ts @@ -0,0 +1,33 @@ +import { body } from "express-validator"; + +// const makeDateValidator = () => +// body("date") +// .exists() +// .withMessage("date needed") +// .bail() +// .isDate() +// .bail(); + +const makeHoursValidator = () => + body("hours") + .exists() + .withMessage("hours needed") + .bail() + .isNumeric() + .withMessage("needs to be number") + .bail(); + +const makeSessionValidator = () => + body("session") + .exists() + .withMessage("sessionId needed") + .bail() + .isString() + .withMessage("needs to be string") + .bail(); + +export const editCalendar = [ + // makeDateValidator(), + makeHoursValidator(), + makeSessionValidator(), +]; diff --git a/backend/src/validators/student.ts b/backend/src/validators/student.ts index 4c7b4499..d8f828f1 100644 --- a/backend/src/validators/student.ts +++ b/backend/src/validators/student.ts @@ -9,6 +9,10 @@ import { programValidatorUtil } from "../util/student"; //designed these to use the globstar operator from express-validator to more cleanly +const isValidDateTimeString = (value: string) => { + return !isNaN(Date.parse(value)); +}; + const makeIdValidator = () => body("**._id") .exists() @@ -63,76 +67,59 @@ const makePhoneNumbersValidator = () => //validate location const makeLocationValidator = () => - body("location") - .exists() - .withMessage("Location field fequired") - .bail() - .isString() - .withMessage("Location must be a string") - .bail() - .notEmpty() - .withMessage("Location field required"); + body("location").optional().isString().withMessage("Location must be a string").bail(); //medication const makeMedicationValidator = () => - body("medication") - .exists() - .withMessage("Medication field required") - .bail() - .isString() - .withMessage("Medication must be a string") - .bail() - .notEmpty() - .withMessage("Medication field required"); + body("medication").optional().isString().withMessage("Medication must be a string").bail(); //birthday const makeBirthdayValidator = () => body("birthday") - .exists() - .withMessage("Birthday field required") - .bail() - .isISO8601() - .toDate() - .withMessage("Birthday string must be a valid date-time string"); + .custom((value) => { + if (!value) { + return true; + } + if (!isValidDateTimeString(value as string)) { + throw new Error("Intake Date string must be a valid date-time string"); + } + return true; + }) + .toDate(); //intake date const makeIntakeDateValidator = () => body("intakeDate") - .exists() - .withMessage("Intake Date field required") - .bail() - .isISO8601() - .toDate() - .withMessage("Intake Date string must be a valid date-time string"); + .custom((value) => { + if (!value) { + return true; + } + if (!isValidDateTimeString(value as string)) { + throw new Error("Intake Date string must be a valid date-time string"); + } + return true; + }) + .toDate(); //tour date const makeTourDateValidator = () => body("tourDate") - .exists() - .withMessage("Tour Date field required") - .bail() - .isISO8601() - .toDate() - .withMessage("Tour Date string must be a valid date-time string"); + .custom((value) => { + if (!value) { + return true; + } + if (!isValidDateTimeString(value as string)) { + throw new Error("Intake Date string must be a valid date-time string"); + } + return true; + }) + .toDate(); const makeConservationValidator = () => - body("conservation") - .exists() - .withMessage("Conservation field required") - .bail() - .isBoolean() - .withMessage("Conservation must be a boolean"); + body("conservation").optional().isBoolean().withMessage("Conservation must be a boolean"); const makeUCINumberValidator = () => - body("UCINumber") - .exists() - .withMessage("UCI Number field required") - .bail() - .isString() - .withMessage("UCI Number must be a string") - .bail() - .notEmpty() - .withMessage("UCI Number field required"); + body("UCINumber").optional().isString().withMessage("UCI Number must be a string").bail(); type DocumentItem = { name: string; @@ -141,21 +128,11 @@ type DocumentItem = { }; const makeIncidentFormValidator = () => - body("incidentForm") - .exists() - .withMessage("Incident Form field required") - .bail() - .isString() - .withMessage("Incident Form must be a string") - .bail() - .notEmpty() - .withMessage("Incident Form field required"); + body("incidentForm").optional().isString().withMessage("Incident Form must be a string").bail(); const makeDocumentsValidator = () => body("documents") - .exists() - .withMessage("Documents field required") - .bail() + .optional() .isArray() .withMessage("Documents must be an array") .bail() @@ -187,9 +164,7 @@ const makeProfilePictureValidator = () => const makeEnrollments = () => body("enrollments") - .exists() - .withMessage("Enrollments field required") - .bail() + .optional() .isArray() .withMessage("Enrollments must be a non-empty array") .bail() diff --git a/frontend/src/api/calendar.ts b/frontend/src/api/calendar.ts new file mode 100644 index 00000000..9664b25c --- /dev/null +++ b/frontend/src/api/calendar.ts @@ -0,0 +1,46 @@ +import { GET, PATCH, createAuthHeader, handleAPIError } from "../api/requests"; + +import type { APIResult } from "../api/requests"; + +export type CalendarResponse = { + studentId: string; + programId: string; + calendar: { + date: Date; + hours: number; + session: string; + }[]; +}; + +export async function getCalendar( + studentId: string, + programId: string, + firebaseToken: string, +): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await GET(`/calendar/${studentId}/${programId}`, headers); + const json = (await response.json()) as CalendarResponse; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function editCalendar( + studentId: string, + programId: string, + firebaseToken: string, + hours: number, + session: string, +): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const body = { hours, session }; + const res = await PATCH(`/calendar/${studentId}/${programId}`, body, headers); + const json = (await res.json()) as string; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/api/programs.ts b/frontend/src/api/programs.ts index 8180c877..97a908be 100644 --- a/frontend/src/api/programs.ts +++ b/frontend/src/api/programs.ts @@ -1,8 +1,6 @@ -import { GET, PATCH, POST, handleAPIError } from "../api/requests"; +import { GET, PATCH, POST, createAuthHeader, handleAPIError } from "../api/requests"; import { CreateProgramRequest } from "../components/ProgramForm/types"; -import { createAuthHeader } from "./progressNotes"; - import type { APIResult } from "../api/requests"; export type Program = CreateProgramRequest & { _id: string; dateUpdated: string }; diff --git a/frontend/src/api/progressNotes.ts b/frontend/src/api/progressNotes.ts index 9a9c45dc..92672000 100644 --- a/frontend/src/api/progressNotes.ts +++ b/frontend/src/api/progressNotes.ts @@ -1,10 +1,14 @@ -import { APIResult, DELETE, GET, POST, PUT, handleAPIError } from "@/api/requests"; +import { + APIResult, + DELETE, + GET, + POST, + PUT, + createAuthHeader, + handleAPIError, +} from "@/api/requests"; import { ProgressNote } from "@/components/ProgressNotes/types"; -export const createAuthHeader = (firebaseToken: string) => ({ - Authorization: `Bearer ${firebaseToken}`, -}); - export async function createProgressNote( studentId: string, dateLastUpdated: Date, diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts index 534285be..6be48936 100644 --- a/frontend/src/api/requests.ts +++ b/frontend/src/api/requests.ts @@ -192,3 +192,7 @@ export function handleAPIError(error: unknown): APIError { } return { success: false, error: `Unknown error; ${String(error)}` }; } + +export const createAuthHeader = (firebaseToken: string) => ({ + Authorization: `Bearer ${firebaseToken}`, +}); diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 8ea97ade..d3f141a9 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,6 +1,4 @@ -import { GET, PATCH, POST, handleAPIError } from "../api/requests"; - -import { createAuthHeader } from "./progressNotes"; +import { GET, PATCH, POST, createAuthHeader, handleAPIError } from "../api/requests"; import type { APIResult } from "../api/requests"; diff --git a/frontend/src/api/students.ts b/frontend/src/api/students.ts index e4cce336..26ce0fae 100644 --- a/frontend/src/api/students.ts +++ b/frontend/src/api/students.ts @@ -1,8 +1,6 @@ -import { DELETE, GET, POST, PUT, handleAPIError } from "../api/requests"; +import { DELETE, GET, POST, PUT, createAuthHeader, handleAPIError } from "../api/requests"; import { StudentData as CreateStudentRequest } from "../components/StudentForm/types"; -import { createAuthHeader } from "./progressNotes"; - import type { APIResult } from "../api/requests"; export type Student = CreateStudentRequest & { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 89418586..ed2d5333 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -30,7 +30,6 @@ export const verifyUser = async (firebaseToken: string): Promise export async function getNotApprovedUsers(firebaseToken: string): Promise> { try { const headers = createAuthHeader(firebaseToken); - console.log(headers); const response = await GET("/user/not-approved", headers); const json = (await response.json()) as User[]; return { success: true, data: json }; diff --git a/frontend/src/components/AttendanceTable.tsx b/frontend/src/components/AttendanceTable.tsx index 87e13cc1..a4b087da 100644 --- a/frontend/src/components/AttendanceTable.tsx +++ b/frontend/src/components/AttendanceTable.tsx @@ -44,6 +44,10 @@ export function AttendanceTable({ const _errors = errors; const dateObj = new Date(session.date.toString()); + console.log(session.date.toString()); + console.log(dateObj); + console.log(dateObj.getDay()); + const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const monthsOfYear = [ "Jan", @@ -136,7 +140,7 @@ export function AttendanceTable({

{daysOfWeek[dateObj.getUTCDay()]}, {monthsOfYear[dateObj.getMonth()]}{" "} - {dateObj.getDate()}{" "} + {dateObj.getUTCDate()}{" "} {program.type === "regular" && "from " + session.sessionTime.start_time + "to " + session.sessionTime.end_time}

diff --git a/frontend/src/components/Calendar/Calendar.tsx b/frontend/src/components/Calendar/Calendar.tsx new file mode 100644 index 00000000..ec092b25 --- /dev/null +++ b/frontend/src/components/Calendar/Calendar.tsx @@ -0,0 +1,108 @@ +import Link from "next/link"; +import { useContext, useEffect, useMemo, useState } from "react"; + +import Back from "../../../public/icons/back.svg"; + +import { CalendarResponse, editCalendar, getCalendar } from "@/api/calendar"; +import { Student, getStudent } from "@/api/students"; +import { CalendarBody } from "@/components/Calendar/CalendarBody"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { UserContext } from "@/contexts/user"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; +import { useWindowSize } from "@/hooks/useWindowSize"; + +export type CalendarProps = { + studentId: string; + programId: string; +}; + +export default function Calendar({ studentId, programId }: CalendarProps) { + useRedirectToLoginIfNotSignedIn(); + + const { firebaseUser } = useContext(UserContext); + + const [currStudent, setStudent] = useState(); + // const [currProgram, setProgram ] = useState(); + const [calendar, setCalendar] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [firebaseToken, setFirebaseToken] = useState(""); + + const { windowSize } = useWindowSize(); + const isMobile = useMemo(() => windowSize.width < 640, [windowSize.width]); + const isTablet = useMemo(() => windowSize.width < 1024, [windowSize.width]); + const extraLarge = useMemo(() => windowSize.width >= 2000, [windowSize.width]); + + useEffect(() => { + if (firebaseUser) { + firebaseUser + ?.getIdToken() + .then(async (token) => { + const calendarResponse = await getCalendar(studentId, programId, token); + if (calendarResponse.success) { + setCalendar(calendarResponse.data); + } + const studentResponse = await getStudent(studentId, token); + if (studentResponse.success) { + setStudent(studentResponse.data); + setIsLoading(false); + } + setFirebaseToken(token); + }) + .catch((error) => { + console.error(error); + }); + } + }, [firebaseUser]); + + const updateCalendar = async (newHours: number, session: string) => { + await editCalendar(studentId, programId, firebaseToken, newHours, session); + }; + + let mainClass = "h-full overflow-y-scroll no-scrollbar flex flex-col"; + let headerClass = "mb-5 font-[alternate-gothic] text-2xl lg:text-4xl "; + let titleClass = "font-[alternate-gothic]"; + const backButton = "flex space-x-0 text-lg"; + + if (isTablet) { + titleClass += " text-2xl leading-none h-6"; + mainClass += " p-0"; + + if (isMobile) { + headerClass += " pt-2 pb-3"; + } else { + headerClass += " p-2 py-4"; + } + } else { + headerClass += "pt-10 pb-5"; + + if (extraLarge) { + headerClass += " max-w-[1740px]"; + } else { + headerClass += " max-w-[1160px]"; + } + } + + return ( +
+ {isLoading ? ( + + ) : ( +
+
+
+ + + + {!isTablet &&

Student List

} +
+

+ {currStudent?.student.firstName + " " + currStudent?.student.lastName} - UCI # + {currStudent?.UCINumber} +

+
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/Calendar/CalendarBody.tsx b/frontend/src/components/Calendar/CalendarBody.tsx new file mode 100644 index 00000000..c4a180f9 --- /dev/null +++ b/frontend/src/components/Calendar/CalendarBody.tsx @@ -0,0 +1,104 @@ +import { Poppins } from "next/font/google"; +import React, { useEffect, useState } from "react"; + +import { Datebox } from "./Datebox"; +import { Day, Months, Weekdays } from "./types"; +import { generateDates } from "./util"; + +import { CalendarResponse } from "@/api/calendar"; + +const poppins = Poppins({ weight: ["400", "700"], style: "normal", subsets: [] }); + +export type CalendarBodyProps = { + calendar?: CalendarResponse; + updateCalendarFunc: (newHours: number, session: string) => Promise; +}; + +// +export const CalendarBody: React.FC = ({ + calendar, + updateCalendarFunc, +}: CalendarBodyProps) => { + const today = new Date(); + + const [month, changeMonth] = useState(today.getMonth()); + const [year, changeYear] = useState(today.getFullYear()); + const [calendarHeader, changeCalendarHeader] = useState(Months[month] + " " + year); + const [dates, changeDates] = useState(generateDates(month, year, calendar)); + + useEffect(() => { + changeCalendarHeader(Months[month] + " " + year); + changeDates(generateDates(month, year, calendar)); + }, [month, year]); + + const decrementMonth = () => { + if (month === 0) { + changeMonth(11); + changeYear(year - 1); + } else { + changeMonth(month - 1); + } + }; + + const incrementMonth = () => { + if (month === 11) { + changeMonth(0); + changeYear(year + 1); + } else { + changeMonth(month + 1); + } + }; + + const bodyClass = `mx-auto w-full border rounded-lg shadow ${poppins.className}`; + + return ( +
+
+ {/* */} +
+

{calendarHeader}

+
+ + +
+
+
+
+ {Weekdays.slice(0, Weekdays.length - 1).map((day, i) => ( +
+ {" "} + {day}{" "} +
+ ))} +
{Weekdays[Weekdays.length - 1]}
+
+
+ {dates.slice(0, dates.length).map((date, i) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/components/Calendar/Datebox.tsx b/frontend/src/components/Calendar/Datebox.tsx new file mode 100644 index 00000000..9019ecd0 --- /dev/null +++ b/frontend/src/components/Calendar/Datebox.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +export type DateboxProps = { + updateCalendarFunc: (newHours: number, session: string) => Promise; + session: string; + day: number; + hours: number; + saturday?: boolean; +}; + +export function Datebox({ updateCalendarFunc, session, day, hours, saturday }: DateboxProps) { + let boxClass = "border-r border-t p-4 flex flex-col items-center"; + if (saturday) { + boxClass = "border-t p-4 flex flex-col items-center"; + } + + const updateCalendar = async (event: React.ChangeEvent) => { + const newHours = Number(event.target.value); + if (isNaN(newHours)) { + return; + } + await updateCalendarFunc(newHours, session); + }; + + return ( +
+
{day}
+ {hours !== -1 && ( + + )} + {hours === -1 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/Calendar/types.ts b/frontend/src/components/Calendar/types.ts new file mode 100644 index 00000000..69678cec --- /dev/null +++ b/frontend/src/components/Calendar/types.ts @@ -0,0 +1,24 @@ +export type Day = { + month: number; + year: number; + day: number; + hours: number; + session: string; +}; + +export const Months: string[] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +export const Weekdays: string[] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; diff --git a/frontend/src/components/Calendar/util.ts b/frontend/src/components/Calendar/util.ts new file mode 100644 index 00000000..c63537e4 --- /dev/null +++ b/frontend/src/components/Calendar/util.ts @@ -0,0 +1,104 @@ +import { Day } from "./types"; + +import { CalendarResponse } from "@/api/calendar"; + +/** + * This function generates the dates for the calendar. + * 0 = Sunday, 1 = Monday, ..., 6 = Saturday + * 0 = Jan, 1 = Fed, ..., 11 = Dec + * @param month + * @param year + * @returns + */ +export const generateDates = (month: number, year: number, calendar?: CalendarResponse): Day[] => { + const days: Day[] = []; + const first = new Date(year, month, 1); + const last = new Date(year, month + 1, 0); + + // get days before start of month + const startDay = first.getDay(); + + for (let i = 0; i < startDay; i++) { + const date = new Date(year, month, i - startDay + 1); + let hours = -1; + let session = ""; + if (calendar) { + for (const c of calendar.calendar) { + const calendarDate = new Date(c.date); + if ( + calendarDate.getMonth() === date.getMonth() && + calendarDate.getDate() === date.getDate() && + calendarDate.getFullYear() === date.getFullYear() + ) { + hours = c.hours; + session = c.session; + } + } + } + days.push({ + month: date.getMonth(), + year: date.getFullYear(), + day: date.getDate(), + hours, + session, + }); + } + + // get current month days + for (let day = 1; day <= last.getDate(); day++) { + const date = new Date(year, month, day); + let hours = -1; + let session = ""; + if (calendar) { + for (const c of calendar.calendar) { + const calendarDate = new Date(c.date); + if ( + calendarDate.getMonth() === date.getMonth() && + calendarDate.getDate() === date.getDate() && + calendarDate.getFullYear() === date.getFullYear() + ) { + hours = c.hours; + session = c.session; + } + } + } + days.push({ + month: date.getMonth(), + year: date.getFullYear(), + day: date.getDate(), + hours, + session, + }); + } + + // get days after end of month + const endDay = last.getDay(); + const endDate = last.getDate(); + for (let i = endDay + 1; i <= 6; i++) { + const date = new Date(year, month, i - endDay + endDate); + let hours = -1; + let session = ""; + if (calendar) { + for (const c of calendar.calendar) { + const calendarDate = new Date(c.date); + if ( + calendarDate.getMonth() === date.getMonth() && + calendarDate.getDate() === date.getDate() && + calendarDate.getFullYear() === date.getFullYear() + ) { + hours = c.hours; + session = c.session; + } + } + } + days.push({ + month: date.getMonth(), + year: date.getFullYear(), + day: date.getDate(), + hours, + session, + }); + } + + return days; +}; diff --git a/frontend/src/components/CalendarTable/CalendarTable.tsx b/frontend/src/components/CalendarTable/CalendarTable.tsx new file mode 100644 index 00000000..dcde0877 --- /dev/null +++ b/frontend/src/components/CalendarTable/CalendarTable.tsx @@ -0,0 +1,129 @@ +import { + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import React, { useContext, useEffect, useMemo, useState } from "react"; + +import LoadingSpinner from "../LoadingSpinner"; +import { Table } from "../ui/table"; + +// eslint-disable-next-line import/order +import Filter, { fuzzyFilter, programFilterFn, statusFilterFn } from "./Filters"; + +// import Filter from "./Filters"; +import TBody from "./TBody"; +import THead from "./THead"; +import { CalendarTableRow } from "./types"; +import { useColumnSchema } from "./useColumnSchema"; + +import { ProgramsContext } from "@/contexts/program"; +// import { UserContext } from "@/contexts/user"; +import { StudentsContext } from "@/contexts/students"; +import { useWindowSize } from "@/hooks/useWindowSize"; +import { cn } from "@/lib/utils"; + +export default function CalendarTable() { + const { allStudents } = useContext(StudentsContext); + const [isLoading, setIsLoading] = useState(true); + const [calendarTable, setCalendarTable] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const { isTablet } = useWindowSize(); + + const { allPrograms } = useContext(ProgramsContext); + + useEffect(() => { + if (allStudents) { + setIsLoading(false); + } + }, [allStudents]); + + // Take all students and put them in rows for table + useEffect(() => { + if (!allStudents) { + return; + } + const tableRows: CalendarTableRow[] = Object.values(allStudents).flatMap((student) => { + // Generate a row for each program the student is enrolled in + return student.enrollments.map( + (enrollment) => + ({ + id: student._id, + profilePicture: "default", + student: `${student.student.firstName} ${student.student.lastName}`, + programs: { + programId: enrollment.programId, + status: enrollment.status, + dateUpdated: enrollment.dateUpdated, + hoursLeft: enrollment.hoursLeft, + studentId: student._id, + }, + }) as CalendarTableRow, + ); + }); + + setCalendarTable(tableRows); + }, [allStudents]); + + const columns = useColumnSchema({ allPrograms }); + const data = useMemo(() => calendarTable, [calendarTable]); + + // have to make different filter functions + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + filterFns: { + fuzzy: fuzzyFilter, + programFilter: programFilterFn, + statusFilter: statusFilterFn, + }, + state: { + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: fuzzyFilter, + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + debugTable: true, + debugHeaders: true, + debugColumns: true, + }); + + return ( +
+
+

+ Calendar +

+ +

Choose a Student to View Attendance

+ + +
+ {isLoading ? ( + + ) : ( + + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/CalendarTable/Filters.tsx b/frontend/src/components/CalendarTable/Filters.tsx new file mode 100644 index 00000000..f1723885 --- /dev/null +++ b/frontend/src/components/CalendarTable/Filters.tsx @@ -0,0 +1,107 @@ +import { RankingInfo, rankItem } from "@tanstack/match-sorter-utils"; +import { FilterFn, Table } from "@tanstack/react-table"; +import React from "react"; + +import SearchIcon from "../../../public/icons/search.svg"; +import DebouncedInput from "../DebouncedInput"; +import { ProgramLink } from "../StudentForm/types"; +import { ProgramFilter } from "../StudentsTable/FilterFns"; + +import { CalendarTableRow } from "./types"; + +// Extend the FilterFns and FilterMeta interfaces +/* eslint-disable */ +declare module "@tanstack/table-core" { + interface FilterFns { + fuzzy: FilterFn; + programFilter: FilterFn; + statusFilter: FilterFn; + } + interface FilterMeta { + itemRank: RankingInfo; + } +} +/* eslint-enable */ + +export const programFilterFn: FilterFn = (rows, id, filterValue) => { + if (filterValue === "") return true; // no filter case + let containsProgram = false; + const programLinks: ProgramLink[] = rows.getValue(id); + programLinks.forEach((prog) => { + if (prog.programId === filterValue && prog.status === "Joined") { + containsProgram = true; + } + }); + return containsProgram; +}; + +export const statusFilterFn: FilterFn = (rows, id, filterValue) => { + if (filterValue === "") return true; // no filter case + let containsStatus = false; + const programLinks: ProgramLink[] = rows.getValue(id); + programLinks.forEach((prog) => { + if (prog.status === filterValue) { + containsStatus = true; + } + }); + return containsStatus; +}; + +// Filter function from tanstack docs for global filter (search in students ) +export const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value as string); + + // Store the itemRank info + addMeta({ + itemRank, + }); + + // Return if the item should be filtered in/out + return itemRank.passed; +}; + +export default function Filter({ + globalFilter, + setGlobalFilter, + table, +}: { + globalFilter: string; + setGlobalFilter: React.Dispatch>; + table: Table; +}) { + return ( +
+ {/* Global Search Filter */} +
+ + { + setGlobalFilter(val); + }} + placeholder="Search in Students" + className="w-full border-none text-gray-600 outline-none" + /> +
+ + {/* Program Filter */} + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + if (header.column.id === "programs" && header.column.getCanFilter()) { + return ( + + ); + } + return null; + })} + + ))} +
+ ); +} diff --git a/frontend/src/components/CalendarTable/TBody.tsx b/frontend/src/components/CalendarTable/TBody.tsx new file mode 100644 index 00000000..96d01565 --- /dev/null +++ b/frontend/src/components/CalendarTable/TBody.tsx @@ -0,0 +1,45 @@ +import { Table, flexRender } from "@tanstack/react-table"; +import Image from "next/image"; + +import { TableBody, TableCell, TableRow } from "../ui/table"; + +import { CalendarTableRow } from "./types"; + +export default function TBody({ table }: { table: Table }) { + // If there are no students, display a placeholder + if (table.getRowModel().rows.length === 0) { + return ( + + + +
+ no students placeholder + + No Students +
+
+
+
+ ); + } + + return ( + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + ); +} diff --git a/frontend/src/components/CalendarTable/THead.tsx b/frontend/src/components/CalendarTable/THead.tsx new file mode 100644 index 00000000..2b5d2a63 --- /dev/null +++ b/frontend/src/components/CalendarTable/THead.tsx @@ -0,0 +1,84 @@ +import { HeaderGroup, Table, flexRender } from "@tanstack/react-table"; +import React from "react"; + +import SearchIcon from "../../../public/icons/search.svg"; +import DebouncedInput from "../DebouncedInput"; +import { TableHead, TableHeader, TableRow } from "../ui/table"; + +import { CalendarTableRow } from "./types"; + +export function TableActionsHeader({ + headerGroup, + globalFilter, + setGlobalFilter, +}: { + headerGroup: HeaderGroup; + globalFilter: string; + setGlobalFilter: React.Dispatch>; +}) { + return ( + + +
+ + {headerGroup.headers.map((header) => { + if (!header.column.getCanFilter()) return null; + return null; + })} + } + value={globalFilter ?? ""} + onChange={(val) => { + setGlobalFilter(val); + }} + placeholder="Search in Students" + className="h-full min-w-[200px] p-0 px-2" + /> + +
+
+
+ ); +} + +function TableDataHeader({ headerGroup }: { headerGroup: HeaderGroup }) { + return ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ); +} + +export default function THead({ + // globalFilter, + // setGlobalFilter, + table, +}: { + // globalFilter: string; + // setGlobalFilter: React.Dispatch>; + table: Table; +}) { + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {/* */} + + + ))} + + ); +} diff --git a/frontend/src/components/CalendarTable/types.ts b/frontend/src/components/CalendarTable/types.ts new file mode 100644 index 00000000..aa03505f --- /dev/null +++ b/frontend/src/components/CalendarTable/types.ts @@ -0,0 +1,16 @@ +import { ColumnDef } from "@tanstack/react-table"; + +import { ProgramLink } from "../StudentForm/types"; + +export type EnrollmentLink = { + studentId: string; +} & ProgramLink; + +export type CalendarTableRow = { + id: string; + profilePicture: string; + student: string; + programs: EnrollmentLink; +}; + +export type Columns = ColumnDef[]; diff --git a/frontend/src/components/CalendarTable/useColumnSchema.tsx b/frontend/src/components/CalendarTable/useColumnSchema.tsx new file mode 100644 index 00000000..913fbdff --- /dev/null +++ b/frontend/src/components/CalendarTable/useColumnSchema.tsx @@ -0,0 +1,62 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useMemo } from "react"; + +import { ProgramMap } from "../StudentsTable/types"; +import { ProgramPill } from "../StudentsTable/useColumnSchema"; + +import { Columns, EnrollmentLink } from "./types"; + +export function useColumnSchema({ allPrograms }: { allPrograms: ProgramMap }) { + const columns: Columns = [ + { + accessorKey: "profilePicture", + header: "Profile Picture", + cell: (info) => ( +
+ Profile Picture +
+ ), + enableColumnFilter: false, + }, + { + accessorKey: "student", + header: "Student Name", + cell: (info) => ( + {info.getValue() as string} + ), + enableColumnFilter: false, + }, + { + accessorKey: "programs", + header: "Attendance", + cell: (info) => { + const enrollmentLink = info.getValue() as unknown as EnrollmentLink; + const studentId = enrollmentLink.studentId; + const programId = enrollmentLink.programId; + const program = allPrograms[programId]; + return ( + + + + ); + }, + }, + ]; + + return useMemo(() => columns, [allPrograms]); +} diff --git a/frontend/src/components/StudentForm/EnrollmentsEdit.tsx b/frontend/src/components/StudentForm/EnrollmentsEdit.tsx index e3b5f319..2dbc0e06 100644 --- a/frontend/src/components/StudentForm/EnrollmentsEdit.tsx +++ b/frontend/src/components/StudentForm/EnrollmentsEdit.tsx @@ -30,7 +30,7 @@ export const emptyEnrollment = { programId: "", status: "", dateUpdated: new Date(), - hoursLeft: 8, + hoursLeft: 0, schedule: [] as string[], sessionTime: { start_time: "", @@ -134,9 +134,68 @@ const EnrollmentFormItem = ({ }, [item.programId]); // these 3 useEffects keep our custom dropdown and react-hook-form in sync + const calculateHoursLeft = ( + startDate: Date, + endDate: Date, + daysOfWeek: string[], + sessionStartTime: string, + sessionEndTime: string, + ): number => { + console.log(startDate, endDate, daysOfWeek, sessionStartTime, sessionEndTime); + const dayMap = { + Su: "Sunday", + M: "Monday", + T: "Tuesday", + W: "Wednesday", + Th: "Thursday", + F: "Friday", + Sa: "Saturday", + }; + + const createDateWithTime = (time: string): Date => { + const [hours, minutes] = time.split(":").map((t) => parseInt(t)); + const newDate = new Date(); + newDate.setHours(hours); + newDate.setMinutes(minutes); + return newDate; + }; + + const days = []; + for (const day of daysOfWeek) { + days.push(dayMap[day as keyof typeof dayMap]); + } + + const sessionStart = createDateWithTime(sessionStartTime); + const sessionEnd = createDateWithTime(sessionEndTime); + const sessionLength = (sessionEnd.getTime() - sessionStart.getTime()) / 1000 / 60 / 60; + + let totalHours = 0; + + for (let d = new Date(startDate); d <= new Date(endDate); d.setDate(d.getDate() + 1)) { + if (days.includes(d.toLocaleString("en-US", { weekday: "long" }))) { + totalHours += sessionLength; + } + } + console.log(totalHours); + + return totalHours; + }; + useEffect(() => { item.sessionTime = selectedSession; setValue(`${fieldName}.${index}.sessionTime`, selectedSession); + if (!varying && selectedSession.start_time && selectedSession.end_time) { + setValue( + `${fieldName}.${index}.hoursLeft`, + calculateHoursLeft( + new Date(item.startDate), + new Date(item.renewalDate), + item.schedule, + selectedSession.start_time, + selectedSession.end_time, + ), + ); + } }, [selectedSession]); useEffect(() => { @@ -198,19 +257,38 @@ const EnrollmentFormItem = ({ ); })} -
-

Session

- { - setSelectedSession(amPmToTime(value)); - }} - /> -
+ {!varying && ( +
+

Session

+ { + setSelectedSession(amPmToTime(value)); + }} + /> +
+ )} + + {varying && ( +
+

Hours Left

+ { + console.log(e.target.value); + setValue(`${fieldName}.${index}.hoursLeft`, Number(e.target.value)); + }} + /> +
+ )} {/* - checkmark -

Student has been saved!

- + + {success && ( +
+ + checkmark +

Student has been saved!

+
+ )} ) : ( + + )}
Regular Programs:
diff --git a/frontend/src/components/StudentProfilePrintComponent.tsx b/frontend/src/components/StudentProfilePrintComponent.tsx index a05f25d4..2d18eb56 100644 --- a/frontend/src/components/StudentProfilePrintComponent.tsx +++ b/frontend/src/components/StudentProfilePrintComponent.tsx @@ -81,9 +81,6 @@ export default function StudentProfilePrintComponent({
Tour Date: {formatDate(data.tourDate)}
Medication & Medical
Dietary Restrictions:
-
- {data.dietary?.map((value) =>
  • {value}
  • )} -
    Medication: {data.medication}
    diff --git a/frontend/src/components/StudentsTable/useColumnSchema.tsx b/frontend/src/components/StudentsTable/useColumnSchema.tsx index 1d062f30..745175ce 100644 --- a/frontend/src/components/StudentsTable/useColumnSchema.tsx +++ b/frontend/src/components/StudentsTable/useColumnSchema.tsx @@ -15,7 +15,7 @@ import { cn } from "@/lib/utils"; const poppins = Poppins({ weight: ["400", "700"], style: "normal", subsets: [] }); -const ProgramPill = ({ name, color }: { name: string; color: string }) => { +export const ProgramPill = ({ name, color }: { name: string; color: string }) => { const { isTablet } = useWindowSize(); return ( diff --git a/frontend/src/constants/navigation.tsx b/frontend/src/constants/navigation.tsx index ce8f4ee2..ce7ebc3d 100644 --- a/frontend/src/constants/navigation.tsx +++ b/frontend/src/constants/navigation.tsx @@ -140,6 +140,24 @@ export const useNavigation = () => { ), }, + { + title: "Calendar", + href: "/calendar", + icon: ( + + + + ), + }, { title: "Attendance", href: "/attendance", diff --git a/frontend/src/pages/calendar.tsx b/frontend/src/pages/calendar.tsx new file mode 100644 index 00000000..7178689b --- /dev/null +++ b/frontend/src/pages/calendar.tsx @@ -0,0 +1,16 @@ +import { useRouter } from "next/router"; + +import Calendar from "@/components/Calendar/Calendar"; +import CalendarTable from "@/components/CalendarTable/CalendarTable"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; + +export default function Component() { + useRedirectToLoginIfNotSignedIn(); + + const router = useRouter(); + const { student, program } = router.query; + + if (!student || !program) return ; + + return ; +}