diff --git a/backend/src/controllers/image.ts b/backend/src/controllers/image.ts new file mode 100644 index 00000000..6c8492b4 --- /dev/null +++ b/backend/src/controllers/image.ts @@ -0,0 +1,74 @@ +/** + * Functions that process image route requests. + */ + +import { NextFunction, Request, Response } from "express"; +import mongoose from "mongoose"; + +import { InternalError } from "../errors"; +import { ServiceError } from "../errors/service"; +import { ValidationError } from "../errors/validation"; +import { Image } from "../models/image"; +import StudentModel from "../models/student"; +import UserModel from "../models/user"; +import { handleImageParsing } from "../util/image"; + +import { OwnerRequestBody } from "./types/types"; +import { EditPhotoRequestBody } from "./types/userTypes"; + +export const editPhoto = (req: EditPhotoRequestBody, res: Response, nxt: NextFunction) => { + try { + //Validation logic inside handleImageParsing + handleImageParsing(req, res, nxt); + } catch (e) { + console.log(e); + nxt(e); + } +}; + +export const getPhoto = async ( + req: Request, Record, OwnerRequestBody>, + res: Response, + nxt: NextFunction, +) => { + try { + const { ownerId, ownerType, imageId } = req.body; + + if (!mongoose.Types.ObjectId.isValid(imageId)) { + return res + .status(ValidationError.INVALID_MONGO_ID.status) + .send({ error: ValidationError.INVALID_MONGO_ID.message }); + } + + let owner = null; + + if (ownerType === "user") { + owner = await UserModel.findById(ownerId); + } else if (ownerType === "student") { + owner = await StudentModel.findById(ownerId); + } + + if (!owner) { + throw ValidationError.USER_NOT_FOUND; + } + + const image = await Image.findById(imageId); + if (!image) { + throw ValidationError.IMAGE_NOT_FOUND; + } + + if (image.ownerId !== ownerId) { + throw ValidationError.IMAGE_USER_MISMATCH; + } + + return res.status(200).set("Content-type", image.mimetype).send(image.buffer); + } catch (e) { + console.log(e); + if (e instanceof ServiceError) { + nxt(e); + } + return res + .status(InternalError.ERROR_GETTING_IMAGE.status) + .send(InternalError.ERROR_GETTING_IMAGE.displayMessage(true)); + } +}; diff --git a/backend/src/controllers/student.ts b/backend/src/controllers/student.ts index 6472e6b0..bfd38601 100644 --- a/backend/src/controllers/student.ts +++ b/backend/src/controllers/student.ts @@ -5,10 +5,12 @@ import { RequestHandler } from "express"; import { validationResult } from "express-validator"; -import createHttpError from "http-errors"; 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"; import { createEnrollment, editEnrollment } from "../util/enrollment"; @@ -23,16 +25,25 @@ export const createStudent: RequestHandler = async (req, res, next) => { validationErrorParser(errors); + const newStudentId = new mongoose.Types.ObjectId(); + const { enrollments, ...studentData } = req.body as StudentRequest; - const newStudent = await StudentModel.create(studentData); + // create enrollments for the student - await Promise.all( + const createdEnrollments = await Promise.all( enrollments.map(async (program: Enrollment) => { - await createEnrollment({ ...program, studentId: newStudent._id }); + return await EnrollmentModel.create({ ...program, studentId: newStudentId }); }), ); - res.status(201).json(newStudent); + const newStudent = await StudentModel.create({ + ...studentData, + enrollments: createdEnrollments.map((enrollment) => enrollment._id), + }); + + const populatedStudent = await StudentModel.findById(newStudent._id).populate("enrollments"); + + res.status(201).json(populatedStudent); } catch (error) { next(error); } @@ -50,42 +61,79 @@ export const editStudent: RequestHandler = async (req, res, next) => { if (studentId !== studentData._id.toString()) { return res.status(400).json({ message: "Invalid student ID" }); } - const updatedStudent = await StudentModel.findByIdAndUpdate(studentId, studentData, { - new: true, - }); - if (!updatedStudent) { - return res.status(404).json({ message: "Student not found" }); + + if (!enrollments) { + const updatedStudent = await StudentModel.findByIdAndUpdate( + studentId, + { ...studentData }, + { + new: true, + }, + ); + if (!updatedStudent) { + return res.status(404).json({ message: "Student not found" }); + } + + return res.status(200).json(updatedStudent); } // update enrollments for the student - await Promise.all( + const updatedEnrollments = await Promise.all( enrollments.map(async (enrollment: Enrollment) => { const enrollmentExists = await EnrollmentModel.findById(enrollment._id); const enrollmentBody = { ...enrollment, studentId: new mongoose.Types.ObjectId(studentId) }; - if (!enrollmentExists) await createEnrollment(enrollmentBody); - else await editEnrollment(enrollmentBody); + const program = await ProgramModel.findById({ _id: enrollment.programId }); + if (program?.type === "regular") { + enrollmentBody.schedule = program.daysOfWeek; + } + if (!enrollmentExists) { + return await createEnrollment(enrollmentBody); + } else { + return await editEnrollment(enrollmentBody); + } }), ); - res.status(200).json({ ...updatedStudent, enrollments }); + const updatedStudent = await StudentModel.findByIdAndUpdate( + studentId, + { ...studentData, enrollments: updatedEnrollments.map((enrollment) => enrollment?._id) }, + { + new: true, + }, + ); + if (!updatedStudent) { + return res.status(404).json({ message: "Student not found" }); + } + + const populatedStudent = await StudentModel.findById(updatedStudent._id).populate( + "enrollments", + ); + + console.log({ populatedStudent }); + + res.status(200).json(populatedStudent); } catch (error) { next(error); } }; -export const getAllStudents: RequestHandler = async (_, res, next) => { +export const getAllStudents: RequestHandler = async (req, res, next) => { try { - const students = await StudentModel.find(); - - // gather all enrollments for each student and put them in student.programs - const hydratedStudents = await Promise.all( - students.map(async (student) => { - const enrollments = await EnrollmentModel.find({ studentId: student._id }); - return { ...student.toObject(), programs: enrollments }; - }), - ); + const students = await StudentModel.find().populate("enrollments"); + + // Even though this is a get request, we have verifyAuthToken middleware that sets the accountType in the request body + const { accountType } = req.body; + + // Ensure that documents that are marked admin are not returned to non-admin users + if (accountType !== "admin") { + students.forEach((student) => { + student.documents = student.documents.filter( + (doc) => !doc.markedAdmin, + ) as typeof student.documents; + }); + } - res.status(200).json(hydratedStudents); + res.status(200).json(students); } catch (error) { next(error); } @@ -93,15 +141,51 @@ export const getAllStudents: RequestHandler = async (_, res, next) => { export const getStudent: RequestHandler = async (req, res, next) => { try { - const id = req.params.id; + const errors = validationResult(req); - const student = await StudentModel.findById(id); + const { accountType } = req.body; - if (student === null) { - throw createHttpError(404, "Student not found"); + validationErrorParser(errors); + + const studentId = req.params.id; + const studentData = await StudentModel.findById(req.params.id); + + if (!studentData) { + return res.status(404).json({ message: "Student not found" }); + } + + // Ensure that documents that are marked admin are not returned to non-admin users + if (accountType !== "admin") { + studentData.documents = studentData.documents.filter( + (doc) => !doc.markedAdmin, + ) as typeof studentData.documents; + } + + const enrollments = await EnrollmentModel.find({ studentId }); + + res.status(200).json({ ...studentData.toObject(), enrollments }); + } catch (error) { + next(error); + } +}; + +export const deleteStudent: RequestHandler = async (req, res, next) => { + try { + const errors = validationResult(req); + validationErrorParser(errors); + + const studentId = req.params.id; + const deletedStudent = await StudentModel.findById(studentId); + if (!deletedStudent) { + return res.status(404).json({ message: "Student not found" }); } - res.status(200).json(student); + await EnrollmentModel.deleteMany({ studentId }); + await ProgressNoteModel.deleteMany({ studentId }); + await Image.deleteMany({ userId: studentId }); + await StudentModel.deleteOne({ _id: studentId }); + + res.status(200).json(deletedStudent); } catch (error) { next(error); } diff --git a/backend/src/controllers/types/types.ts b/backend/src/controllers/types/types.ts index 2f755e63..8ddcda18 100644 --- a/backend/src/controllers/types/types.ts +++ b/backend/src/controllers/types/types.ts @@ -5,3 +5,21 @@ export type UserId = { }; export type UserIdRequestBody = Request & UserId; + +// Add type so that if uploadType is "create", new field imgeId is required +type NewUploadType = { + uploadType: "new"; + imageId: string; +}; + +type EditUploadType = { + uploadType: "edit"; + imageId: never; +}; + +export type OwnerInfo = { + ownerId: string; + ownerType: string; +} & (NewUploadType | EditUploadType); + +export type OwnerRequestBody = Request & OwnerInfo; diff --git a/backend/src/controllers/types/userTypes.ts b/backend/src/controllers/types/userTypes.ts index 3756d246..d1d9731f 100644 --- a/backend/src/controllers/types/userTypes.ts +++ b/backend/src/controllers/types/userTypes.ts @@ -1,6 +1,6 @@ import { Request } from "express"; -import { UserId } from "./types"; +import { OwnerInfo, UserId } from "./types"; export type CreateUserRequestBody = { name: string; @@ -32,7 +32,10 @@ export type UpdateAccountTypeRequestBody = UserId & { export type SaveImageRequest = { body: { previousImageId: string; - userId: string; + ownerId: string; + ownerType: string; + uploadType: string; + imageId: string; }; file: { buffer: Buffer; @@ -42,6 +45,10 @@ export type SaveImageRequest = { }; }; -export type EditPhotoRequestBody = Request, Record, UserId> & { +export type EditPhotoRequestBody = Request< + Record, + Record, + OwnerInfo +> & { rawBody?: Buffer; }; diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 88389bf7..492c67ac 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -1,16 +1,11 @@ import { NextFunction, Request, Response } from "express"; import { validationResult } from "express-validator"; import admin from "firebase-admin"; -import mongoose from "mongoose"; -import { InternalError } from "../errors"; -import { ServiceError } from "../errors/service"; import { ValidationError } from "../errors/validation"; -import { Image } from "../models/image"; import UserModel from "../models/user"; import { sendApprovalEmail, sendDenialEmail } from "../util/email"; import { firebaseAdminAuth } from "../util/firebase"; -import { handleImageParsing } from "../util/image"; import { deleteUserFromFirebase, deleteUserFromMongoDB } from "../util/user"; import validationErrorParser from "../util/validationErrorParser"; @@ -20,7 +15,6 @@ import { EditEmailRequestBody, EditLastChangedPasswordRequestBody, EditNameRequestBody, - EditPhotoRequestBody, LoginUserRequestBody, UpdateAccountTypeRequestBody, } from "./types/userTypes"; @@ -174,57 +168,6 @@ export const loginUser = async ( } }; -export const editPhoto = (req: EditPhotoRequestBody, res: Response, nxt: NextFunction) => { - try { - //Validation logic inside handleImageParsing - handleImageParsing(req, res, nxt); - } catch (e) { - console.log(e); - nxt(e); - } -}; - -export const getPhoto = async ( - req: Request, Record, UserIdRequestBody>, - res: Response, - nxt: NextFunction, -) => { - try { - const { uid } = req.body; - const { id: imageId } = req.params; - - if (!mongoose.Types.ObjectId.isValid(imageId)) { - return res - .status(ValidationError.INVALID_MONGO_ID.status) - .send({ error: ValidationError.INVALID_MONGO_ID.message }); - } - - const user = await UserModel.findById(uid); - if (!user) { - throw ValidationError.USER_NOT_FOUND; - } - - const image = await Image.findById(imageId); - if (!image) { - throw ValidationError.IMAGE_NOT_FOUND; - } - - if (image.userId !== uid) { - throw ValidationError.IMAGE_USER_MISMATCH; - } - - return res.status(200).set("Content-type", image.mimetype).send(image.buffer); - } catch (e) { - console.log(e); - if (e instanceof ServiceError) { - nxt(e); - } - return res - .status(InternalError.ERROR_GETTING_IMAGE.status) - .send(InternalError.ERROR_GETTING_IMAGE.displayMessage(true)); - } -}; - export const editName = async ( req: Request, Record, EditNameRequestBody>, res: Response, diff --git a/backend/src/errors/validation.ts b/backend/src/errors/validation.ts index 0ad6af68..5cca8481 100644 --- a/backend/src/errors/validation.ts +++ b/backend/src/errors/validation.ts @@ -11,6 +11,7 @@ const IMAGE_NOT_FOUND = "Image was not found. Please make sure id passed in rout const INVALID_MONGO_ID = "Mongo ID was invalid. Please ensure that the id is correct"; const IMAGE_USER_MISMATCH = "Image does not belong to the user"; const PROGRESS_NOTE_NOT_FOUND = "Progress note not found in database"; +const IMAGE_UPDATED_FAILED = "Image was not updated successfully"; export class ValidationError extends CustomError { static USER_CREATION_UNSUCCESSFUL = new ValidationError(1, 400, USER_CREATION_UNSUCCESSFUL); @@ -24,4 +25,5 @@ export class ValidationError extends CustomError { static INVALID_MONGO_ID = new ValidationError(9, 400, INVALID_MONGO_ID); static IMAGE_USER_MISMATCH = new ValidationError(10, 401, IMAGE_USER_MISMATCH); static PROGRESS_NOTE_NOT_FOUND = new ValidationError(11, 404, PROGRESS_NOTE_NOT_FOUND); + static IMAGE_UPDATED_FAILED = new ValidationError(12, 400, IMAGE_UPDATED_FAILED); } diff --git a/backend/src/models/image.ts b/backend/src/models/image.ts index 18479d62..9089367b 100644 --- a/backend/src/models/image.ts +++ b/backend/src/models/image.ts @@ -11,7 +11,8 @@ type ImageInterface = { originalname: string; mimetype: string; size: number; - userId: string; + ownerId: string; + ownerType: string; }; type ImageDoc = mongoose.Document & { @@ -19,7 +20,8 @@ type ImageDoc = mongoose.Document & { originalname: string; mimetype: string; size: number; - userId: string; + ownerId: string; + ownerType: string; }; type ImageModelInterface = mongoose.Model & { @@ -43,7 +45,11 @@ const imageSchema = new mongoose.Schema({ type: Number, required: true, }, - userId: { + ownerId: { + type: String, + required: true, + }, + ownerType: { type: String, required: true, }, diff --git a/backend/src/models/student.ts b/backend/src/models/student.ts index 26242fed..ac19c756 100644 --- a/backend/src/models/student.ts +++ b/backend/src/models/student.ts @@ -25,6 +25,13 @@ const studentSchema = new Schema({ phoneNumber: { type: String, required: true }, }, + enrollments: { + type: [Schema.Types.ObjectId], + ref: "Enrollment", + default: [], + required: false, + }, + //Address of student location: { type: String, required: true }, @@ -38,8 +45,18 @@ const studentSchema = new Schema({ conservation: { type: Boolean, required: true }, UCINumber: { type: String, required: true }, incidentForm: { type: String, required: true }, - documents: { type: [String], required: true }, - profilePicture: { type: Schema.Types.ObjectId, ref: "Image", required: false }, + documents: { + type: [ + { + name: { type: String, required: true }, + link: { type: String, required: true }, + markedAdmin: { type: Boolean, required: true, default: false }, + }, + ], + required: true, + }, + profilePicture: { type: String, ref: "Image", required: false, default: "default" }, + progressNotes: { type: [Schema.Types.ObjectId], ref: "ProgressNote", diff --git a/backend/src/routes/api.ts b/backend/src/routes/api.ts index 05be9288..cc1a9c90 100644 --- a/backend/src/routes/api.ts +++ b/backend/src/routes/api.ts @@ -1,6 +1,7 @@ import express from "express"; import calendarRouter from "./calendar"; +import imageRouter from "./image"; import programRoutes from "./program"; import progressNoteRoutes from "./progressNote"; import sessionRoutes from "./session"; @@ -16,5 +17,6 @@ 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/image.ts b/backend/src/routes/image.ts new file mode 100644 index 00000000..a5e0567e --- /dev/null +++ b/backend/src/routes/image.ts @@ -0,0 +1,14 @@ +/** + * Image route requests. + */ + +import express from "express"; + +import * as ImageController from "../controllers/image"; +import { verifyAuthToken } from "../validators/auth"; +const router = express.Router(); + +router.post("/edit", [verifyAuthToken], ImageController.editPhoto); +router.post("/get", [verifyAuthToken], ImageController.getPhoto); + +export default router; diff --git a/backend/src/routes/program.ts b/backend/src/routes/program.ts index 50132b90..9e9e47f5 100644 --- a/backend/src/routes/program.ts +++ b/backend/src/routes/program.ts @@ -5,15 +5,26 @@ import express from "express"; // { RequestHandler } import * as ProgramController from "../controllers/program"; +import { verifyAuthToken } from "../validators/auth"; import * as ProgramValidator from "../validators/program"; const router = express.Router(); -router.patch("/:id", ProgramValidator.updateProgram, ProgramController.updateProgram); -router.post("/create", ProgramValidator.createProgram, ProgramController.createProgram); -router.post("/archive/:id", ProgramController.archiveProgram); -router.get("/all", ProgramController.getAllPrograms); -router.get("/:id", ProgramController.getProgram); -router.get("/enrollments/:id", ProgramController.getProgramEnrollments); +router.patch( + "/:id", + [verifyAuthToken], + ProgramValidator.updateProgram, + ProgramController.updateProgram, +); +router.post( + "/create", + [verifyAuthToken], + ProgramValidator.createProgram, + ProgramController.createProgram, +); +router.post("/archive/:id", [verifyAuthToken], ProgramController.archiveProgram); +router.get("/all", [verifyAuthToken], ProgramController.getAllPrograms); +router.get("/:id", [verifyAuthToken], ProgramController.getProgram); +router.get("/enrollments/:id", [verifyAuthToken], ProgramController.getProgramEnrollments); export default router; diff --git a/backend/src/routes/session.ts b/backend/src/routes/session.ts index f8e16d12..03528755 100644 --- a/backend/src/routes/session.ts +++ b/backend/src/routes/session.ts @@ -5,15 +5,22 @@ import express from "express"; import * as SessionController from "../controllers/session"; +import { verifyAuthToken } from "../validators/auth"; import * as SessionValidator from "../validators/session"; const router = express.Router(); -router.get("/get", SessionController.getRecentSessions); -router.get("/getAbsences", SessionController.getAbsenceSessions); -router.patch("/mark", SessionValidator.updateSession, SessionController.updateSession); +router.get("/get", [verifyAuthToken], SessionController.getRecentSessions); +router.get("/getAbsences", [verifyAuthToken], SessionController.getAbsenceSessions); +router.patch( + "/mark", + [verifyAuthToken], + SessionValidator.updateSession, + SessionController.updateSession, +); router.post( "/markAbsence", + [verifyAuthToken], SessionValidator.absenceSession, SessionController.createAbsenceSession, ); diff --git a/backend/src/routes/student.ts b/backend/src/routes/student.ts index 952cb84a..5baa0150 100644 --- a/backend/src/routes/student.ts +++ b/backend/src/routes/student.ts @@ -5,13 +5,24 @@ import express from "express"; import * as StudentController from "../controllers/student"; +import { verifyAuthToken } from "../validators/auth"; import * as StudentValidator from "../validators/student"; - const router = express.Router(); -router.post("/create", StudentValidator.createStudent, StudentController.createStudent); -router.put("/edit/:id", StudentValidator.editStudent, StudentController.editStudent); -router.get("/all", StudentController.getAllStudents); -router.get("/:id", StudentController.getStudent); +router.post( + "/create", + [verifyAuthToken], + StudentValidator.createStudent, + StudentController.createStudent, +); +router.put( + "/edit/:id", + [verifyAuthToken], + ...StudentValidator.editStudent, + StudentController.editStudent, +); +router.get("/all", [verifyAuthToken], StudentController.getAllStudents); +router.get("/:id", [verifyAuthToken], StudentController.getStudent); +router.delete("/:id", [verifyAuthToken], StudentController.deleteStudent); export default router; diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 55470528..1ab2ee07 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -19,8 +19,6 @@ router.delete("/delete/:email", [verifyAuthToken], UserController.deleteUser); router.get("/not-approved", [verifyAuthToken], UserController.getNotApprovedUsers); router.get("/", [verifyAuthToken], UserController.loginUser); -router.post("/editPhoto", [verifyAuthToken], UserValidator.editPhoto, UserController.editPhoto); -router.get("/getPhoto/:id", [verifyAuthToken], UserController.getPhoto); router.patch("/editName", [verifyAuthToken], UserValidator.editName, UserController.editName); router.patch("/editEmail", [verifyAuthToken], UserValidator.editEmail, UserController.editEmail); router.patch( diff --git a/backend/src/util/enrollment.ts b/backend/src/util/enrollment.ts index 0c92e3c1..792365a4 100644 --- a/backend/src/util/enrollment.ts +++ b/backend/src/util/enrollment.ts @@ -3,7 +3,7 @@ import { Enrollment } from "../types/enrollment"; export const createEnrollment = async (req: Enrollment) => { try { - await EnrollmentModel.create(req); + return await EnrollmentModel.create(req); } catch (e) { console.log(e); throw e; @@ -13,7 +13,7 @@ export const createEnrollment = async (req: Enrollment) => { export const editEnrollment = async (req: Enrollment) => { try { console.log(req); - await EnrollmentModel.findByIdAndUpdate(req._id, req); + return await EnrollmentModel.findByIdAndUpdate(req._id, req); } catch (e) { console.log(e); throw e; diff --git a/backend/src/util/image.ts b/backend/src/util/image.ts index 7ef31726..3851131e 100644 --- a/backend/src/util/image.ts +++ b/backend/src/util/image.ts @@ -2,63 +2,98 @@ import busboy from "busboy"; import { NextFunction, Response } from "express"; import mongoose from "mongoose"; +import { OwnerInfo } from "../controllers/types/types"; import { EditPhotoRequestBody, SaveImageRequest } from "../controllers/types/userTypes"; import { ValidationError } from "../errors"; import { ServiceError } from "../errors/service"; import { Image } from "../models/image"; +import StudentModel from "../models/student"; import UserModel from "../models/user"; // Write the type for the request body type SaveImageRequestBody = { previousImageId: string; - userId: string; -}; +} & OwnerInfo; async function saveImage(req: SaveImageRequest) { - const { previousImageId, userId } = req.body as SaveImageRequestBody; + const { previousImageId, ownerId, ownerType, uploadType, imageId } = + req.body as SaveImageRequestBody; try { //Update existing image if possible - if (previousImageId !== "default" && req.file?.buffer) { + if (previousImageId !== "default" && req.file?.buffer && uploadType === "edit") { + console.log("Updating an image in the database"); + const image = await Image.findById(previousImageId); if (!image) { throw ValidationError.IMAGE_NOT_FOUND; } //Verify that the image belongs to the user - if (image.userId !== userId) { + if (image.ownerId !== ownerId) { throw ValidationError.IMAGE_USER_MISMATCH; } - console.log("Updating an image in the database"); // Update the image document with new data - image.buffer = req.file?.buffer; - image.originalname = req.file?.originalname; - image.mimetype = req.file?.mimetype; - image.size = req.file?.size; + const updatedImageFields = { + buffer: req.file?.buffer, + originalname: req.file?.originalname, + mimetype: req.file?.mimetype, + size: req.file?.size, + }; + + const updatedImage = await Image.findByIdAndUpdate(previousImageId, updatedImageFields, { + new: true, + }); + + if (!updatedImage) { + throw ValidationError.IMAGE_UPDATED_FAILED; + } - const updatedImage = await image.save(); return updatedImage._id as mongoose.Types.ObjectId; } else { // Create new image if there is no previous image console.log("Adding a new image to the database"); - const newImage = new Image({ + + const imageData = { buffer: req.file?.buffer, originalname: req.file?.originalname, mimetype: req.file?.mimetype, size: req.file?.size, - userId, - }); + ownerId, + ownerType, + }; + + let newImage = new Image(imageData); + + // This is for editing/creating Student profile picture that is default + if (imageId) { + newImage = new Image({ ...imageData, _id: imageId }); + await newImage.save(); + return imageId; + } + // This is for editing Profile page picture that is default const savedImage = await newImage.save(); - const user = await UserModel.findById(userId); - if (!user) { + let owner = null; + + if (ownerType === "user") { + owner = await UserModel.findById(ownerId); + } else if (ownerType === "student") { + owner = await StudentModel.findById(ownerId); + } + + if (!owner) { throw ValidationError.USER_NOT_FOUND; } const savedImageId = savedImage._id as mongoose.Types.ObjectId; - await UserModel.findByIdAndUpdate(userId, { profilePicture: savedImageId }); + if (ownerType === "user") { + await UserModel.findByIdAndUpdate(ownerId, { profilePicture: savedImageId }); + } else if (ownerType === "student") { + await StudentModel.findByIdAndUpdate(ownerId, { profilePicture: savedImageId }); + } return savedImageId; } @@ -70,8 +105,10 @@ async function saveImage(req: SaveImageRequest) { export function handleImageParsing(req: EditPhotoRequestBody, res: Response, nxt: NextFunction) { let previousImageId = ""; - //req.userId is assigned in verifyAuthToken middleware - const uid = req.body.uid; + let ownerId = ""; + let ownerType = ""; + let uploadType = ""; + let imageId = ""; const bb = busboy({ headers: req.headers }); @@ -80,6 +117,18 @@ export function handleImageParsing(req: EditPhotoRequestBody, res: Response, nxt if (fieldname === "previousImageId") { previousImageId = val; } + if (fieldname === "ownerId") { + ownerId = val; + } + if (fieldname === "ownerType") { + ownerType = val; + } + if (fieldname === "uploadType") { + uploadType = val; + } + if (fieldname === "imageId") { + imageId = val; + } }); bb.on("file", (name, file, info) => { const { filename, mimeType } = info; @@ -92,7 +141,10 @@ export function handleImageParsing(req: EditPhotoRequestBody, res: Response, nxt const saveImageRequest: SaveImageRequest = { body: { previousImageId, - userId: uid, + ownerId, + ownerType, + uploadType, + imageId, }, file: { buffer: fileBuffer, @@ -136,17 +188,3 @@ export function handleImageParsing(req: EditPhotoRequestBody, res: Response, nxt req.pipe(bb); } } - -export async function deletePreviousImage(imageId: string): Promise { - console.info("Deleting previous image from the database"); - if (!imageId) { - return; - } - - await Image.findByIdAndDelete(imageId); - - const image = await Image.findById(imageId); - if (!image) { - throw ValidationError.IMAGE_NOT_FOUND; - } -} diff --git a/backend/src/util/student.ts b/backend/src/util/student.ts index 39c564ec..c194c02b 100644 --- a/backend/src/util/student.ts +++ b/backend/src/util/student.ts @@ -23,7 +23,7 @@ export const programValidatorUtil = async (enrollments: Enrollment[]) => { }); // verify statuses are correct and student is not in more than 2 programs - const allowedStatuses = ["Joined", "Waitlisted", "Archived", "Not a fit"]; + const allowedStatuses = ["Joined", "Waitlisted", "Archived", "Not a fit", "Completed"]; const programIds = new Set(); let active = 0; let varying = 0; diff --git a/backend/src/validators/auth.ts b/backend/src/validators/auth.ts index ac2b6797..a38d22f1 100644 --- a/backend/src/validators/auth.ts +++ b/backend/src/validators/auth.ts @@ -11,7 +11,7 @@ import { decodeAuthToken } from "../util/auth"; type RequestBody = { uid: string; - role: string; + accountType: string; }; type RequestWithUserId = Request & { @@ -42,7 +42,7 @@ const verifyAuthToken = async (req: RequestWithUserId, res: Response, next: Next if (userInfo) { req.body.uid = userInfo.user_id as string; - req.body.role = userInfo.role as string; + req.body.accountType = userInfo.accountType as string; next(); return; } diff --git a/backend/src/validators/student.ts b/backend/src/validators/student.ts index aa7254d8..4c7b4499 100644 --- a/backend/src/validators/student.ts +++ b/backend/src/validators/student.ts @@ -134,6 +134,12 @@ const makeUCINumberValidator = () => .notEmpty() .withMessage("UCI Number field required"); +type DocumentItem = { + name: string; + link: string; + markedAdmin: boolean; +}; + const makeIncidentFormValidator = () => body("incidentForm") .exists() @@ -153,8 +159,28 @@ const makeDocumentsValidator = () => .isArray() .withMessage("Documents must be an array") .bail() - .custom((value: string[]) => value.every((doc) => typeof doc === "string")) - .withMessage("Documents must be an array of strings"); + .custom((value: unknown): value is DocumentItem[] => { + // Type guard to ensure value is an array of DocumentItem + return ( + Array.isArray(value) && + value.every( + (doc): doc is DocumentItem => + typeof doc === "object" && + doc !== null && + // Check 'name' property + typeof doc.name === "string" && + doc.name.trim() !== "" && + // Check 'link' property + typeof doc.link === "string" && + doc.link.trim() !== "" && + // Check 'markedAdmin' property (optional, must be boolean if present) + (doc.markedAdmin === undefined || typeof doc.markedAdmin === "boolean"), + ) + ); + }) + .withMessage( + "Documents must be an array of objects with 'name', 'link', and 'markedAdmin' properties", + ); const makeProfilePictureValidator = () => body("profilePicture").optional().isString().withMessage("Profile picture must be a string"); @@ -169,24 +195,6 @@ const makeEnrollments = () => .bail() .custom(programValidatorUtil); -//dietary -//validates entire array -const makeDietaryArrayValidator = () => - body("dietary") - .optional() - .exists() - .isArray() - .withMessage("Dietary restrictions must be an array"); -//validates individual items -const makeDietaryItemsValidator = () => - body("dietary.*").exists().isString().withMessage("Dietary restriction element must be a string"); - -const makeDietaryOtherValidator = () => - body("otherString") - .optional() - .isString() - .withMessage("Other dietary restriction must be a string"); - export const createStudent = [ makeLastNamesValidator(), makeFirstNamesValidator(), @@ -203,9 +211,12 @@ export const createStudent = [ makeDocumentsValidator(), makeProfilePictureValidator(), makeEnrollments(), - makeDietaryArrayValidator(), - makeDietaryItemsValidator(), - makeDietaryOtherValidator(), ]; -export const editStudent = [...createStudent, makeIdValidator()]; +export const editStudent = [ + makeIdValidator(), + [...createStudent].map((validator) => { + validator.optional(); + return validator; + }), +]; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5fd73087..bca14a42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "autoprefixer": "^10.4.16", + "bson": "^6.10.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^3.2.0", @@ -31,6 +32,7 @@ "react-day-picker": "^8.10.0", "react-dom": "^18", "react-hook-form": "^7.49.3", + "react-to-print": "^3.0.2", "tailwind-merge": "^2.2.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", @@ -5702,6 +5704,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.0.tgz", + "integrity": "sha512-ROchNosXMJD2cbQGm84KoP7vOGPO6/bOAW0veMMbzhXLqoZptcaYRVLitwvuhwhjjpU1qP4YZRWLhgETdgqUQw==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "devOptional": true, @@ -10130,6 +10140,15 @@ } } }, + "node_modules/react-to-print": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.0.2.tgz", + "integrity": "sha512-FS/Z4LLq0bgWaxd7obygFQ8yRFdKW74iE8fIVjFFsPJWIXmuL8CIO+4me1Hj44lrlxQ00gscSNb3BRM8olbwXg==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" + } + }, "node_modules/read-cache": { "version": "1.0.0", "license": "MIT", diff --git a/frontend/package.json b/frontend/package.json index 2db45b74..164b52f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", "autoprefixer": "^10.4.16", + "bson": "^6.10.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^3.2.0", @@ -38,6 +39,7 @@ "react-day-picker": "^8.10.0", "react-dom": "^18", "react-hook-form": "^7.49.3", + "react-to-print": "^3.0.2", "tailwind-merge": "^2.2.0", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/public/back-arrow.svg b/frontend/public/back-arrow.svg new file mode 100644 index 00000000..eb477f06 --- /dev/null +++ b/frontend/public/back-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/email.svg b/frontend/public/email.svg new file mode 100644 index 00000000..18231135 --- /dev/null +++ b/frontend/public/email.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/exclamation.svg b/frontend/public/exclamation.svg new file mode 100644 index 00000000..a4ec890d --- /dev/null +++ b/frontend/public/exclamation.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/public/icons/close.svg b/frontend/public/icons/close.svg index 4b9cb438..9c45ee67 100644 --- a/frontend/public/icons/close.svg +++ b/frontend/public/icons/close.svg @@ -1,3 +1,3 @@ - + diff --git a/frontend/public/icons/green_question_mark.svg b/frontend/public/icons/green_question_mark.svg new file mode 100644 index 00000000..6fb6bda8 --- /dev/null +++ b/frontend/public/icons/green_question_mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/icons/plant.svg b/frontend/public/icons/plant.svg new file mode 100644 index 00000000..43a8b410 --- /dev/null +++ b/frontend/public/icons/plant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/pencil.svg b/frontend/public/pencil.svg new file mode 100644 index 00000000..0ee21ad2 --- /dev/null +++ b/frontend/public/pencil.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/phone.svg b/frontend/public/phone.svg new file mode 100644 index 00000000..26283cbf --- /dev/null +++ b/frontend/public/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/plus.svg b/frontend/public/plus.svg new file mode 100644 index 00000000..c6985994 --- /dev/null +++ b/frontend/public/plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/profile-icon.svg b/frontend/public/profile-icon.svg new file mode 100644 index 00000000..d295805b --- /dev/null +++ b/frontend/public/profile-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/api/programs.ts b/frontend/src/api/programs.ts index d69a320d..97a908be 100644 --- a/frontend/src/api/programs.ts +++ b/frontend/src/api/programs.ts @@ -1,4 +1,4 @@ -import { GET, PATCH, POST, handleAPIError } from "../api/requests"; +import { GET, PATCH, POST, createAuthHeader, handleAPIError } from "../api/requests"; import { CreateProgramRequest } from "../components/ProgramForm/types"; import type { APIResult } from "../api/requests"; @@ -13,15 +13,23 @@ export type Enrollment = { dateUpdated: Date; hoursLeft: number; schedule: string[]; - sessionTime: string[]; + sessionTime: { + start_time: string; + end_time: string; + }; + required: true; startDate: Date; renewalDate: Date; authNumber: string; }; -export async function createProgram(program: CreateProgramRequest): Promise> { +export async function createProgram( + program: CreateProgramRequest, + firebaseToken: string, +): Promise> { try { - const response = await POST("/program/create", program); + const headers = createAuthHeader(firebaseToken); + const response = await POST("/program/create", program, headers); const json = (await response.json()) as Program; console.log({ json }); return { success: true, data: json }; @@ -30,9 +38,10 @@ export async function createProgram(program: CreateProgramRequest): Promise> { +export async function getProgram(id: string, firebaseToken: string): Promise> { try { - const response = await GET(`/program/${id}`); + const headers = createAuthHeader(firebaseToken); + const response = await GET(`/program/${id}`, headers); const json = (await response.json()) as Program; return { success: true, data: json }; } catch (error) { @@ -40,9 +49,13 @@ export async function getProgram(id: string): Promise> { } } -export async function getProgramEnrollments(id: string): Promise> { +export async function getProgramEnrollments( + id: string, + firebaseToken: string, +): Promise> { try { - const response = await GET(`/program/enrollments/${id}`); + const headers = createAuthHeader(firebaseToken); + const response = await GET(`/program/enrollments/${id}`, headers); const json = (await response.json()) as [Enrollment]; return { success: true, data: json }; } catch (error) { @@ -50,9 +63,13 @@ export async function getProgramEnrollments(id: string): Promise> { +export async function editProgram( + program: Program, + firebaseToken: string, +): Promise> { try { - const response = await PATCH(`/program/${program._id}`, program); + const headers = createAuthHeader(firebaseToken); + const response = await PATCH(`/program/${program._id}`, program, headers); const json = (await response.json()) as Program; console.log({ json }); return { success: true, data: json }; @@ -61,9 +78,10 @@ export async function editProgram(program: Program): Promise> } } -export async function getAllPrograms(): Promise> { +export async function getAllPrograms(firebaseToken: string): Promise> { try { - const response = await GET("/program/all"); + const headers = createAuthHeader(firebaseToken); + const response = await GET("/program/all", headers); const json = (await response.json()) as [Program]; return { success: true, data: json }; } catch (error) { @@ -71,9 +89,13 @@ export async function getAllPrograms(): Promise> { } } -export async function archiveProgram(program: Program): Promise> { +export async function archiveProgram( + program: Program, + firebaseToken: string, +): Promise> { try { - const response = await POST(`/program/archive/${program._id}`, undefined); + const headers = createAuthHeader(firebaseToken); + const response = await POST(`/program/archive/${program._id}`, undefined, headers); const json = (await response.json()) as Program; return { success: true, data: json }; } catch (error) { diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index b43f5e71..d3f141a9 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,4 +1,4 @@ -import { GET, PATCH, POST, handleAPIError } from "../api/requests"; +import { GET, PATCH, POST, createAuthHeader, handleAPIError } from "../api/requests"; import type { APIResult } from "../api/requests"; @@ -33,9 +33,10 @@ export type AbsenceCreateBody = { }; }; -export async function getRecentSessions(): Promise> { +export async function getRecentSessions(firebaseToken: string): Promise> { try { - const response = await GET("/session/get"); + const headers = createAuthHeader(firebaseToken); + const response = await GET("/session/get", headers); const json = (await response.json()) as [Session]; return { success: true, data: json }; } catch (error) { @@ -43,9 +44,12 @@ export async function getRecentSessions(): Promise> { } } -export async function getAbsenceSessions(): Promise> { +export async function getAbsenceSessions( + firebaseToken: string, +): Promise> { try { - const response = await GET("/session/getAbsences"); + const headers = createAuthHeader(firebaseToken); + const response = await GET("/session/getAbsences", headers); const json = (await response.json()) as [AbsenceSession]; return { success: true, data: json }; } catch (error) { @@ -53,9 +57,13 @@ export async function getAbsenceSessions(): Promise> } } -export async function updateSession(session: Session): Promise> { +export async function updateSession( + session: Session, + firebaseToken: string, +): Promise> { try { - const response = await PATCH(`/session/mark`, session); + const headers = createAuthHeader(firebaseToken); + const response = await PATCH(`/session/mark`, session, headers); const json = (await response.json()) as Session; return { success: true, data: json }; } catch (error) { @@ -65,9 +73,11 @@ export async function updateSession(session: Session): Promise> { try { - const response = await POST(`/session/markAbsence`, session); + const headers = createAuthHeader(firebaseToken); + const response = await POST(`/session/markAbsence`, session, headers); const json = (await response.json()) as Session; return { success: true, data: json }; } catch (error) { diff --git a/frontend/src/api/students.ts b/frontend/src/api/students.ts index 369b2621..26ce0fae 100644 --- a/frontend/src/api/students.ts +++ b/frontend/src/api/students.ts @@ -1,18 +1,27 @@ -import { 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 type { APIResult } from "../api/requests"; export type Student = CreateStudentRequest & { _id: string; - medication: string; - otherString: string; progressNotes?: string[]; + UCINumber?: string; + conservation?: boolean; + profilePicture: string; }; -export async function createStudent(student: CreateStudentRequest): Promise> { +type EditStudentRequest = { + _id: string; +} & Partial; + +export async function createStudent( + student: CreateStudentRequest, + firebaseToken: string, +): Promise> { try { - const response = await POST("/student/create", student); + const headers = createAuthHeader(firebaseToken); + const response = await POST("/student/create", student, headers); const json = (await response.json()) as Student; console.log({ json }); return { success: true, data: json }; @@ -21,9 +30,13 @@ export async function createStudent(student: CreateStudentRequest): Promise> { +export async function editStudent( + student: EditStudentRequest, + firebaseToken: string, +): Promise> { try { - const response = await PUT(`/student/edit/${student._id}`, student); + const headers = createAuthHeader(firebaseToken); + const response = await PUT(`/student/edit/${student._id}`, student, headers); const json = (await response.json()) as Student; return { success: true, data: json }; } catch (error) { @@ -31,9 +44,10 @@ export async function editStudent(student: Student): Promise> } } -export async function getAllStudents(): Promise> { +export async function getAllStudents(firebaseToken: string): Promise> { try { - const response = await GET("/student/all"); + const headers = createAuthHeader(firebaseToken); + const response = await GET("/student/all", headers); const json = (await response.json()) as [Student]; return { success: true, data: json }; } catch (error) { @@ -41,9 +55,24 @@ export async function getAllStudents(): Promise> { } } -export async function getStudent(id: string): Promise> { +export async function getStudent(id: string, firebaseToken: string): Promise> { + try { + const headers = createAuthHeader(firebaseToken); + const response = await GET(`/student/${id}`, headers); + const json = (await response.json()) as Student; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function deleteStudent( + id: string, + firebaseToken: string, +): Promise> { try { - const response = await GET(`/student/${id}`); + const headers = createAuthHeader(firebaseToken); + const response = await DELETE(`/student/${id}`, undefined, headers); const json = (await response.json()) as Student; return { success: true, data: json }; } catch (error) { diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 8b87e3da..89418586 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -98,15 +98,24 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api"; export async function editPhoto( form: FormData, previousImageId: string, + ownerId: string, + ownerType: string, + uploadType: string, + imageId: string, firebaseToken: string, ): Promise> { try { form.append("previousImageId", previousImageId); + form.append("ownerId", ownerId); + form.append("ownerType", ownerType); + form.append("uploadType", uploadType); + form.append("imageId", imageId); + const method = "POST"; const headers = createAuthHeader(firebaseToken); // Don't use the POST function from requests.ts because we need to send a FormData object - const response = await fetch(`${API_BASE_URL}/user/editPhoto`, { + const response = await fetch(`${API_BASE_URL}/image/edit`, { method, body: form, headers, @@ -128,11 +137,14 @@ export async function editPhoto( export async function getPhoto( imageId: string, + ownerId: string, + ownerType: string, firebaseToken: string, ): Promise> { try { + const imageData = { imageId, ownerId, ownerType }; const headers = createAuthHeader(firebaseToken); - const response = await GET(`/user/getPhoto/${imageId}`, headers); + const response = await POST(`/image/get`, imageData, headers); if (response.ok) { const blob = await response.blob(); diff --git a/frontend/src/components/AttendanceCard.tsx b/frontend/src/components/AttendanceCard.tsx index c855f9c2..bc1d7c53 100644 --- a/frontend/src/components/AttendanceCard.tsx +++ b/frontend/src/components/AttendanceCard.tsx @@ -17,9 +17,15 @@ export type CardProps = { program: Program; student: Student; setRemainingSessions: Dispatch>; + firebaseToken: string; }; -export function AttendanceCard({ program, student, setRemainingSessions }: CardProps) { +export function AttendanceCard({ + program, + student, + setRemainingSessions, + firebaseToken, +}: CardProps) { const { register, setValue, @@ -43,7 +49,7 @@ export function AttendanceCard({ program, student, setRemainingSessions }: CardP student: { studentId: student._id, attended: true, hoursAttended: data.hoursAttended }, }; console.log(newAbsenceSession); - createAbsenceSession(newAbsenceSession) + createAbsenceSession(newAbsenceSession, firebaseToken) .then((res) => { if (res.success) { console.log(res); diff --git a/frontend/src/components/AttendanceTable.tsx b/frontend/src/components/AttendanceTable.tsx index de89ac28..87e13cc1 100644 --- a/frontend/src/components/AttendanceTable.tsx +++ b/frontend/src/components/AttendanceTable.tsx @@ -21,6 +21,7 @@ export type TableProps = { setRemainingSessions: Dispatch>; setAllAbsenceSessions: Dispatch>; setRemainingAbsenceSessions: Dispatch>; + firebaseToken: string; }; export function AttendanceTable({ @@ -30,6 +31,7 @@ export function AttendanceTable({ setRemainingSessions, setAllAbsenceSessions, setRemainingAbsenceSessions, + firebaseToken, }: TableProps) { const { register, @@ -77,7 +79,7 @@ export function AttendanceTable({ }); session.students = studentInfo; session.marked = true; - updateSession(session) + updateSession(session, firebaseToken) .then((newSession) => { if (newSession.success) { const absentStudents = newSession.data.students.filter((student) => !student.attended); diff --git a/frontend/src/components/Calendar/Calendar.tsx b/frontend/src/components/Calendar/Calendar.tsx index 86fa6854..f5d4ccb6 100644 --- a/frontend/src/components/Calendar/Calendar.tsx +++ b/frontend/src/components/Calendar/Calendar.tsx @@ -40,7 +40,7 @@ export default function Calendar({ studentId, programId }: CalendarProps) { if (calendarResponse.success) { setCalendar(calendarResponse.data); } - const studentResponse = await getStudent(studentId); + const studentResponse = await getStudent(studentId, token); if (studentResponse.success) { setStudent(studentResponse.data); setIsLoading(false); diff --git a/frontend/src/components/CalendarTable/CalendarTable.tsx b/frontend/src/components/CalendarTable/CalendarTable.tsx index 87147306..dcde0877 100644 --- a/frontend/src/components/CalendarTable/CalendarTable.tsx +++ b/frontend/src/components/CalendarTable/CalendarTable.tsx @@ -8,11 +8,10 @@ import { } from "@tanstack/react-table"; import React, { useContext, useEffect, useMemo, useState } from "react"; -import { getAllStudents } from "../../api/students"; import LoadingSpinner from "../LoadingSpinner"; -import { StudentMap } from "../StudentsTable/types"; import { Table } from "../ui/table"; +// eslint-disable-next-line import/order import Filter, { fuzzyFilter, programFilterFn, statusFilterFn } from "./Filters"; // import Filter from "./Filters"; @@ -23,54 +22,45 @@ 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, setAllStudents] = useState({}); + const { allStudents } = useContext(StudentsContext); const [isLoading, setIsLoading] = useState(true); const [calendarTable, setCalendarTable] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); const { isTablet } = useWindowSize(); const { allPrograms } = useContext(ProgramsContext); - // const { isAdmin } = useContext(UserContext); - // get all students and store them in allStudents useEffect(() => { - getAllStudents().then( - (result) => { - console.log(result); - if (result.success) { - // Convert student array to object with keys as ids and values as corresponding student - const studentsObject = result.data.reduce((obj, student) => { - obj[student._id] = student; - return obj; - }, {} as StudentMap); - - setAllStudents(studentsObject); - setIsLoading(false); - } else { - console.log(result.error); - } - }, - (error) => { - console.log(error); - }, - ); - }, []); + 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.programs.map( - (program) => + return student.enrollments.map( + (enrollment) => ({ id: student._id, profilePicture: "default", student: `${student.student.firstName} ${student.student.lastName}`, - programs: { ...program, studentId: student._id }, + programs: { + programId: enrollment.programId, + status: enrollment.status, + dateUpdated: enrollment.dateUpdated, + hoursLeft: enrollment.hoursLeft, + studentId: student._id, + }, }) as CalendarTableRow, ); }); diff --git a/frontend/src/components/CalendarTable/Filters.tsx b/frontend/src/components/CalendarTable/Filters.tsx index b6a23767..f1723885 100644 --- a/frontend/src/components/CalendarTable/Filters.tsx +++ b/frontend/src/components/CalendarTable/Filters.tsx @@ -61,41 +61,6 @@ export const fuzzyFilter: FilterFn = (row, columnId, value, ad return itemRank.passed; }; -// export default function Filter({ -// globalFilter, -// setGlobalFilter, -// table, -// }: { -// globalFilter: string; -// setGlobalFilter: React.Dispatch>; -// table: Table; -// }) { -// return ( -//
-// {table.getHeaderGroups().map((headerGroup) => ( -// -// } -// value={globalFilter ?? ""} -// onChange={(val) => { -// setGlobalFilter(val); -// }} -// placeholder="Search in Students" -// className="h-full min-w-[300px] p-0 px-2 border border-gray-300 rounded-md bg-white" -// /> -// {headerGroup.headers.map((header) => { -// if (!header.column.getCanFilter()) return null; -// if (header.column.id === "programs") { -// return ; -// } -// return null; -// })} -// -// ))} -//
-// ); -// } - export default function Filter({ globalFilter, setGlobalFilter, diff --git a/frontend/src/components/Checkbox.tsx b/frontend/src/components/Checkbox.tsx index 0606498b..1cba2bb8 100644 --- a/frontend/src/components/Checkbox.tsx +++ b/frontend/src/components/Checkbox.tsx @@ -24,7 +24,7 @@ export function Checkbox({ defaultValue = defaultValue?.map((item) => item.toLowerCase()); return ( -
+
{options.map((item, index) => { return item === "Other" ? ( ({ = { label?: string; placeholder: string; defaultValue?: string; + initialValue?: string; className?: string; }; @@ -27,14 +28,15 @@ type DropdownProps = BaseProps & { export function Dropdown({ setDropdownValue, - label, + placeholder, name, - options, + options, // list of options - should be memoized onChange = () => void 0, - defaultValue = "", + defaultValue, // value if you want a permanent default label that is not really a value (see home page dropdowns) + initialValue, // value if you want a default value that is a value in the list of options (see create/edit student dropdowns) className, }: DropdownProps) { - const [selectedOption, setSelectedOption] = useState(defaultValue); + const [selectedOption, setSelectedOption] = useState(defaultValue ?? initialValue ?? ""); const [open, setOpen] = useState(false); const triggerRef = useRef(null); @@ -58,12 +60,13 @@ export function Dropdown({ - {label + ": "} - {selectedOption ?? ""} + + {selectedOption ? selectedOption : placeholder} + ({ /> - { - onChange(""); - setSelectedOption(defaultValue); - }} - > - {defaultValue} - + {defaultValue && ( + { + onChange(""); + setSelectedOption(defaultValue); + }} + > + {defaultValue} + + )} {options.map((option) => ( -
+
+
Loading Spinner -

Loading...

+

{label}

); diff --git a/frontend/src/components/Modals/ModalConfirmation.tsx b/frontend/src/components/Modals/ModalConfirmation.tsx index 0561190b..86eadc17 100644 --- a/frontend/src/components/Modals/ModalConfirmation.tsx +++ b/frontend/src/components/Modals/ModalConfirmation.tsx @@ -5,6 +5,8 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger } from "../ui/dialog" type ModalConfirmationProps = { ref?: React.RefObject; + // Optional dialog styling + className?: string; icon: React.ReactNode; triggerElement: React.ReactNode; onCancelClick?: (e: React.MouseEvent) => void; @@ -15,6 +17,8 @@ type ModalConfirmationProps = { confirmText: string; kind: "primary" | "destructive"; nestedDialog?: React.ReactNode; + //Used for inner react content (between title and buttons) + innerContent?: React.ReactNode; //Used for nested dialog to close parent dialog isParentOpen?: boolean; setIsParentOpen?: React.Dispatch>; @@ -23,6 +27,7 @@ type ModalConfirmationProps = { const ModalConfirmation = forwardRef( ( { + className, icon, triggerElement, onCancelClick = () => {}, @@ -33,6 +38,7 @@ const ModalConfirmation = forwardRef( confirmText, kind, nestedDialog, + innerContent, isParentOpen, setIsParentOpen, }, @@ -43,12 +49,17 @@ const ModalConfirmation = forwardRef( {triggerElement}
{icon}

{title}

{description ?

{description}

: null} + {innerContent}
*/} +
+ + {varying && ( + + )} + + ); +}; + +function EnrollmentsEdit({ classname, data, varying }: EnrollmentsEditProps) { + const { control, watch } = useFormContext(); + const { allPrograms: programsMap } = useContext(ProgramsContext); + + // used for dropdown options + const varyingPrograms = useMemo( + () => + Object.values(programsMap) + .filter((program) => program.type === "varying") + .map((program) => program.abbreviation), + [programsMap], + ); + const regularPrograms = useMemo( + () => + Object.values(programsMap) + .filter((program) => program.type === "regular") + .map((program) => program.abbreviation), + [programsMap], + ); + + const fieldName = varying ? "varyingEnrollments" : "regularEnrollments"; + const { fields, append, update } = useFieldArray({ + control, + name: fieldName, + shouldUnregister: true, + }); + + watch(fieldName, fields); + + useEffect(() => { + if (data) { + data.enrollments + .filter((enrollment) => { + if (varying) { + return programsMap[enrollment.programId].type === "varying"; + } else { + return programsMap[enrollment.programId].type === "regular"; + } + }) + .forEach((enrollment, index) => { + update(index, { + ...enrollment, + varying, + dateUpdated: new Date(enrollment.dateUpdated), + // messy way to format dates since mongo returns them as strings with exact time + startDate: convertDateToString(new Date(enrollment.startDate)), + renewalDate: convertDateToString(new Date(enrollment.renewalDate)), + }); + }); + } + }, [data]); + + if (!programsMap) return null; + + return ( +
+
+ +

{varying ? "Varying" : "Regular"} Programs

+ +
+
    + {fields + .filter( + (enrollment) => + // if we have a program id present, we can filter by whether it is regular or varying, otherwise its probably a new entry + !enrollment.programId || + (varying + ? programsMap[enrollment.programId]?.type === "varying" + : programsMap[enrollment.programId]?.type === "regular"), + ) + .map((item, index) => { + return ( +
  • + +
  • + ); + })} +
+
+
+ ); +} + +export default EnrollmentsEdit; diff --git a/frontend/src/components/StudentForm/StudentBackground.tsx b/frontend/src/components/StudentForm/StudentBackground.tsx index 25a19bc6..30757c97 100644 --- a/frontend/src/components/StudentForm/StudentBackground.tsx +++ b/frontend/src/components/StudentForm/StudentBackground.tsx @@ -1,20 +1,30 @@ -import { UseFormRegister, UseFormSetValue } from "react-hook-form"; +import { AlertCircle } from "lucide-react"; +import Image from "next/image"; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import { useFormContext } from "react-hook-form"; import { Student } from "../../api/students"; import { cn } from "../../lib/utils"; +import { Button } from "../Button"; import { Checkbox } from "../Checkbox"; +import LoadingSpinner from "../LoadingSpinner"; +import SaveCancelButtons from "../Modals/SaveCancelButtons"; import { Textfield } from "../Textfield"; +import { Dialog, DialogContent } from "../ui/dialog"; import { StudentFormData } from "./types"; +import { getPhoto } from "@/api/user"; + type StudentBackgroundProps = { - register: UseFormRegister; classname?: string; - setCalendarValue: UseFormSetValue; data: Student | null; + type: "edit" | "add"; + setImageFormData: Dispatch>; + firebaseToken: string; }; -const dietaryList = ["Nuts", "Eggs", "Seafood", "Pollen", "Dairy", "Other"]; +const conservationList = ["Yes", "No"]; export const convertDateToString = (date: Date | undefined) => { return date @@ -28,54 +38,223 @@ export const convertDateToString = (date: Date | undefined) => { export default function StudentBackground({ data, - register, classname, - setCalendarValue, + type, + setImageFormData, + firebaseToken, }: StudentBackgroundProps) { + const { register, setValue: setCalendarValue } = useFormContext(); + const [modalOpen, setModalOpen] = useState(false); + + const [imageFile, setImageFile] = useState(); + const [previousImage, setPreviousImage] = useState(data?.profilePicture ?? "default"); + const [imagePreview, setImagePreview] = useState(""); + const [clickedAddProfile, setClickedAddProfile] = useState(false); + const [imageError, setImageError] = useState(""); + + const [_openSaveCancel, setOpenSaveCancel] = useState(false); + const fileUploadRef = useRef(null); + + useEffect(() => { + if (firebaseToken) { + if (data?.profilePicture === "default" || type === "add" || !data) { + setImagePreview("default"); + return; + } + getPhoto(data.profilePicture, data._id, "student", firebaseToken).then( + (result) => { + if (result.success) { + const newImage = result.data; + setImagePreview(newImage); + setPreviousImage(newImage); + } else { + console.error(result.error); + } + }, + (error) => { + console.error(error); + }, + ); + } + }, [firebaseToken]); + + const onCancelImage = () => { + setClickedAddProfile(false); + if (imagePreview && imagePreview !== "default") URL.revokeObjectURL(imagePreview); //Prevent memory leaks + setImagePreview(previousImage ?? "default"); + setImageError(""); + }; + + const onSaveImage = () => { + if (!imageFile) { + setImageError("Please upload an image"); + return; + } + + const formData = new FormData(); + formData.append("image", imageFile); + + setImageFormData(formData); + setModalOpen(false); + setClickedAddProfile(false); + }; + + const handleImageUpload = (e: React.ChangeEvent) => { + if (e.target?.files?.[0]) { + const newProfilePicture = URL.createObjectURL(e.target.files[0]); + setPreviousImage(imagePreview); + setImagePreview(newProfilePicture); + setImageFile(e.target.files[0]); + } + setClickedAddProfile(true); + }; + return ( -
-
-
-

Address

- -
-
-

Birthdate

- -
-
-

Medication

- -
+
+
+

Address

+ +
+
+

Birthdate

+
-
-

Dietary Restrictions

+
+

Conserved

+
+

Medication and Medical

+ +
+
+ +

Profile Picture

+ +
+ {imagePreview ? ( +
+ { + setModalOpen(true); + }} + className="h-full w-full rounded-full object-cover" + src={imagePreview !== "default" ? imagePreview : "/defaultProfilePic.svg"} + alt="Profile Picture" + height="85" + width="85" + /> +
+ ) : ( + + )} +
+ + + +
+ +

Edit Photo

+
+ Profile Picture +
+

+ + + {imageError} +

+ {clickedAddProfile ? ( + + ) : ( +
+
+ )} +
+
+
); } diff --git a/frontend/src/components/StudentForm/StudentForm.tsx b/frontend/src/components/StudentForm/StudentForm.tsx new file mode 100644 index 00000000..9573a1fc --- /dev/null +++ b/frontend/src/components/StudentForm/StudentForm.tsx @@ -0,0 +1,332 @@ +import { ObjectId } from "bson"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { Dispatch, SetStateAction, createContext, useContext, useEffect, useState } from "react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; + +import { Student, createStudent, editStudent } from "../../api/students"; +import { cn } from "../../lib/utils"; +import { Button } from "../Button"; +import SaveCancelButtons from "../Modals/SaveCancelButtons"; +import { StudentMap } from "../StudentsTable/types"; + +import ContactInfo from "./ContactInfo"; +import EnrollmentsEdit from "./EnrollmentsEdit"; +import StudentBackground from "./StudentBackground"; +import StudentInfo from "./StudentInfo"; +import { useHandleImageDocumentUpload } from "./hooks/useHandleImageDocumentUpload"; +import { Document, StudentData, StudentFormData } from "./types"; + +import { ProgramsContext } from "@/contexts/program"; +import { StudentsContext } from "@/contexts/students"; +import { UserContext } from "@/contexts/user"; + +type BaseProps = { + classname?: string; + setCurrentView: Dispatch>; + // Used to update single student data if editing on StudentProfile page + setStudentData?: Dispatch>; +}; + +type EditProps = BaseProps & { + type: "edit"; + data: Student | null; +}; + +type AddProps = BaseProps & { + type: "add"; + data?: Student | null; +}; + +type StudentFormProps = EditProps | AddProps; + +export const FormContext = createContext({} as StudentFormData); + +export default function StudentForm({ + type, + data = null, //Student data so form can be populated + classname, + setCurrentView, + setStudentData, +}: StudentFormProps) { + const [openSaveDialog, setOpenSaveDialog] = useState(false); + const router = useRouter(); + const methods = useForm(); + const { handleSubmit } = methods; + const { setAllStudents } = useContext(StudentsContext); + const { allPrograms } = useContext(ProgramsContext); + const { isAdmin, firebaseUser } = useContext(UserContext); + const [firebaseToken, setFirebaseToken] = useState(""); + + const newStudentId = new ObjectId().toHexString(); + const newImageId = new ObjectId().toHexString(); + + const [imageFormData, setImageFormData] = useState(null); + // These are all uploaded files that are in memory. + const [currentFiles, setCurrentFiles] = useState([]); + const [studentDocuments, setStudentDocuments] = useState(data?.documents ?? []); + const [didDeleteOrMark, setDidDeleteOrMark] = useState(false); + + const documentData = { + currentFiles, + setCurrentFiles, + studentDocuments, + setStudentDocuments, + setDidDeleteOrMark, + }; + + const { handleAddingNewImage, handleUploadingDocument, handleDidDeleteOrMark } = + useHandleImageDocumentUpload({ + imageDataProp: { + imageFormData, + newStudentId, + newImageId, + type, + data, + firebaseToken, + }, + documentDataProp: { + currentFiles, + studentId: data?._id ?? newStudentId, + type, + studentDocuments, + setStudentDocuments, + didDeleteOrMark, + previousDocuments: data?.documents, + }, + }); + + useEffect(() => { + if (!firebaseUser) return; + if (firebaseUser) { + firebaseUser + ?.getIdToken() + .then((token) => { + setFirebaseToken(token); + }) + .catch((error) => { + console.error(error); + }); + } + }, [firebaseUser]); + + const onFormSubmit: SubmitHandler = async (formData: StudentFormData) => { + const programAbbreviationToId = {} as Record; // abbreviation -> programId + Object.values(allPrograms).forEach( + (program) => (programAbbreviationToId[program.abbreviation] = program._id), + ); + + let newProfilePictureLink = "default"; + + if (imageFormData && data?.profilePicture === "default") { + newProfilePictureLink = newImageId; + } else { + newProfilePictureLink = formData.profilePicture; + } + + const transformedData: StudentData = { + _id: data?._id ?? newStudentId, + student: { + firstName: formData.studentName, + lastName: formData.studentLast, + email: formData.studentEmail, + phoneNumber: formData.studentPhone, + }, + emergency: { + firstName: formData.emergencyName, + lastName: formData.emergencyLast, + email: formData.emergencyEmail, + phoneNumber: formData.emergencyPhone, + }, + serviceCoordinator: { + firstName: formData.serviceCoordinatorName, + lastName: formData.serviceCoordinatorLast, + email: formData.serviceCoordinatorEmail, + phoneNumber: formData.serviceCoordinatorPhone, + }, + location: formData.address, + medication: formData.medication, + birthday: new Date(formData.birthdate), + intakeDate: new Date(formData.intakeDate), + tourDate: new Date(formData.tourDate), + enrollments: formData?.regularEnrollments + .map((enrollment) => { + console.log("enrollment regular: ", enrollment); + return { + ...enrollment, + dateUpdated: new Date(enrollment.dateUpdated), + startDate: new Date(enrollment.startDate), + renewalDate: new Date(enrollment.renewalDate), + sessionTime: { + start_time: enrollment.sessionTime.start_time, + end_time: enrollment.sessionTime.end_time, + }, + }; + }) + .concat( + formData?.varyingEnrollments.map((enrollment) => { + console.log("enrollment varying: ", enrollment); + return { + ...enrollment, + dateUpdated: new Date(enrollment.dateUpdated), + startDate: new Date(enrollment.startDate), + renewalDate: new Date(enrollment.renewalDate), + sessionTime: { + start_time: enrollment.sessionTime.start_time, + end_time: enrollment.sessionTime.end_time, + }, + }; + }), + ), + conservation: formData.conservation === "yes", + UCINumber: formData.UCINumber, + incidentForm: formData.incidentForm, + documents: studentDocuments, + profilePicture: newProfilePictureLink, + }; + + if (imageFormData) { + const uploadedImageId = await handleAddingNewImage(); + transformedData.profilePicture = uploadedImageId; + } + + if (currentFiles.length > 0) { + const newDocumentData = await handleUploadingDocument(); + transformedData.documents = newDocumentData; + } + + if (didDeleteOrMark) { + handleDidDeleteOrMark(); + + // If no files were uploaded, we need to update the documents field. Otherwise it is updated by the if statement above. + if (currentFiles.length === 0) { + transformedData.documents = studentDocuments; + } + } + + if (type === "add") { + createStudent(transformedData, firebaseToken).then( + (result) => { + if (result.success) { + const newStudent = result.data; + // reset(); // only clear form on success + console.log("Student created successfully"); + setAllStudents((prevStudents: StudentMap | undefined) => { + return { ...prevStudents, [newStudent._id]: { ...newStudent } }; + }); + } else { + console.log(result.error); + alert("Unable to create student: " + result.error); + } + }, + (error) => { + console.log(error); + }, + ); + } + + if (type === "edit" && data) { + const editedData: Student = { ...transformedData, _id: data._id }; + editStudent(editedData, firebaseToken).then( + (result) => { + if (result.success) { + const editedStudent = result.data; + if (setStudentData) { + setStudentData(editedStudent); + } + setAllStudents((prevStudents: StudentMap | undefined) => { + if (!prevStudents) return prevStudents; + if (Object.keys(prevStudents).includes(editedStudent._id)) { + return { ...prevStudents, [editedStudent._id]: { ...editedStudent } }; + } else { + console.log("Student ID is invalid"); + return prevStudents; + } + }); + } else { + console.log(result.error); + alert("Unable to edit student: " + result.error); + } + }, + (error) => { + console.log(error); + }, + ); + } + + setTimeout(() => { + setCurrentView("View"); + }, 1500); + }; + + return ( +
+ +
+
+ +
+
+
+ + Student Background + + +
+
+ + Student Information + + + +
+
+
+
+ +
+
+ +
+
+ +
+ {/* Modal Confirmation Dialog */} + {isAdmin ? ( + { + router.push("/home"); + }} + > + {/* Save Dialog Content */} +
+ + checkmark +

Student has been saved!

+
+
+ ) : ( +
+
+
+
+ ); +} diff --git a/frontend/src/components/StudentForm/StudentInfo.tsx b/frontend/src/components/StudentForm/StudentInfo.tsx index b2058b06..c92672e5 100644 --- a/frontend/src/components/StudentForm/StudentInfo.tsx +++ b/frontend/src/components/StudentForm/StudentInfo.tsx @@ -1,90 +1,469 @@ -import { useContext, useMemo } from "react"; -import { UseFormRegister, UseFormSetValue } from "react-hook-form"; +// Dashed Border Credit: https://kovart.github.io/dashed-border-generator/ +import { AlertCircle } from "lucide-react"; +import Image from "next/image"; +import { + Dispatch, + DragEvent, + Fragment, + SetStateAction, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import { useFormContext } from "react-hook-form"; + +import CloseIcon from "../../../public/icons/close.svg"; +import GreenQuestionIcon from "../../../public/icons/green_question_mark.svg"; +import RedDeleteIcon from "../../../public/icons/red_delete.svg"; import { Student } from "../../api/students"; import { cn } from "../../lib/utils"; -import { Checkbox } from "../Checkbox"; +import { Button } from "../Button"; +import ModalConfirmation from "../Modals/ModalConfirmation"; +import SaveCancelButtons from "../Modals/SaveCancelButtons"; import { Textfield } from "../Textfield"; +import { Dialog, DialogContent } from "../ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { convertDateToString } from "./StudentBackground"; -import { StudentFormData } from "./types"; +import styles from "./styles/StudentInfo.module.css"; +import { Document, StudentFormData } from "./types"; -import { Program } from "@/api/programs"; import { ProgramsContext } from "@/contexts/program"; type StudentInfoProps = { - register: UseFormRegister; classname?: string; - setCalendarValue: UseFormSetValue; data: Student | null; + documentData: { + currentFiles: File[]; + studentDocuments: Document[]; + setCurrentFiles: Dispatch>; + setStudentDocuments: Dispatch>; + setDidDeleteOrMark: Dispatch>; + }; + isAdmin: boolean; }; -export default function StudentInfo({ - register, - classname, - setCalendarValue, - data, -}: StudentInfoProps) { +const SUPPORTED_FILETYPES = [ + "application/pdf", + "image/png", + "image/jpg", + "image/jpeg", + "image/webp", +]; + +export default function StudentInfo({ classname, data, documentData, isAdmin }: StudentInfoProps) { + const { register, setValue: setCalendarValue } = useFormContext(); + const [modalOpen, setModalOpen] = useState(false); + const [documentError, setDocumentError] = useState(""); + const fileUploadRef = useRef(null); + const [_openSaveCancel, setOpenSaveCancel] = useState(false); + + const { + currentFiles, + studentDocuments, + setCurrentFiles, + setStudentDocuments, + setDidDeleteOrMark, + } = documentData; + // These are temporary files that only exist in the upload file modal + const [modalFiles, setModalFiles] = useState([]); + + const [isDragging, setIsDragging] = useState(false); + const dropZoneRef = useRef(null); + + const existingDocumentNames = studentDocuments.map((doc) => doc.name); + const { allPrograms: programsMap } = useContext(ProgramsContext); const allPrograms = useMemo(() => Object.values(programsMap), [programsMap]); if (!allPrograms) return null; + // console.log("All files in memory (Current Files)", currentFiles); + // console.log("All files in modal (Modal Files)", modalFiles); + // console.log("All files including database (Student Documents)", studentDocuments); + + const uploadDocument = () => { + setDocumentError(""); + + // This is invalid because the links are empty. This will be checked before the edit student API is called + const temporaryDocumentData = modalFiles.map((file) => { + return { link: "", name: file.name, markedAdmin: false }; + }); + + setCurrentFiles((prev) => [...prev, ...modalFiles]); + setStudentDocuments((prev) => [...prev, ...temporaryDocumentData]); + setModalOpen(false); + setModalFiles([]); + }; + + const handleImageUpload = (files: File[] | null) => { + if (files) { + const tempFiles = [...Array.from(files)]; + const containsLargeFiles = tempFiles.some((file) => file.size > 10 * 1024 * 1024); + if (containsLargeFiles) { + setDocumentError("File size should not exceed 10MB"); + return; + } + setDocumentError(""); + const containsInvalidFiles = tempFiles.some( + (file) => !SUPPORTED_FILETYPES.includes(file.type), + ); + if (containsInvalidFiles) { + setDocumentError("Invalid file type. Please upload a PDF, PNG, JPG, or WEBP file."); + return; + } + const duplicateFiles = tempFiles.filter((file) => existingDocumentNames.includes(file.name)); + if (duplicateFiles.length > 0) { + setDocumentError("Document with this name already exists."); + return; + } + + const fileNames = tempFiles.map((file) => file.name); + const fileSet = new Set(fileNames); + + if (fileSet.size !== fileNames.length) { + setDocumentError("Duplicate files are not allowed."); + return; + } + setModalFiles(tempFiles); + } + }; + + const dropHandler = (event: DragEvent, onFilesDrop: (files: File[]) => void) => { + // Prevent default behavior (Prevent file from being opened) + event.preventDefault(); + + const droppedFiles: File[] = []; + + if (event.dataTransfer.items) { + // Use DataTransferItemList interface + Array.from(event.dataTransfer.items).forEach((item) => { + // Only process if the dropped item is a file + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + droppedFiles.push(file); + } + } + }); + } else { + // Fallback to DataTransfer interface + Array.from(event.dataTransfer.files).forEach((file) => { + droppedFiles.push(file); + }); + } + + // Call the provided callback with the files + onFilesDrop(droppedFiles); + }; + + const handleMarkAdmin = (document: Document) => { + setStudentDocuments((prev) => + prev.map((doc) => + doc.name === document.name ? { ...doc, markedAdmin: !doc.markedAdmin } : doc, + ), + ); + }; + + const handleDeleteDocument = (document: Document) => { + setStudentDocuments((prev) => prev.filter((doc) => doc.name !== document.name)); + setCurrentFiles((prev) => prev.filter((file) => file.name !== document.name)); + }; + + const TruncateDocument = ({ + documentName, + documentLength, + }: { + documentName: string; + documentLength: number; + }) => { + const minLength = 9; // Shortest truncation + const maxLength = 20; // Longest truncation + const extension = documentName.split(".").pop(); + const baseName = documentName.slice(0, documentName.lastIndexOf(".")); + + // Use an inverse relationship: fewer documents = longer names + const dynamicLength = Math.max( + minLength, + Math.min(maxLength, 20 - Math.floor((documentLength - 1) * 2)), + ); + + // Only truncate and add ellipsis if the basename is longer than dynamicLength + const displayName = + baseName.length > dynamicLength ? baseName.substring(0, dynamicLength) + "..." : baseName; + + return ( +
+ {displayName} + {extension?.toUpperCase()} +
+ ); + }; + + console.log({ isAdmin }); + return ( -
-
-
-

Intake date

- -
-
-

Tour date

- -
+
+
+

Intake date

+
-
-
-

Regular Programs

- prog.type === "regular") - .slice(0, 2) - .map((program: Program) => program.abbreviation)} - defaultValue={data?.programs.map( - (program) => programsMap[program.programId].abbreviation, +
+

Tour date

+ +
+
+

Incident Form

+ +
+
+

UCI Number

+ +
+
+ +

Documents

+ +
+
    + {studentDocuments?.map((document) => ( + + + +
  • + +
  • +
    + + {document.link && ( + + )} + + {isAdmin ? ( + <> + } + triggerElement={ + + } + title={document.markedAdmin ? "Unmark admin?" : "Mark admin only?"} + description={`${document.markedAdmin ? "Everyone will be able to" : "Only admin will"} see these files`} + confirmText={document.markedAdmin ? "Unmark" : "Mark"} + kind="primary" + onConfirmClick={() => { + handleMarkAdmin(document); + setDidDeleteOrMark(true); + }} + /> + } + triggerElement={ + + } + title="Are you sure you want to delete?" + confirmText="Delete" + kind="destructive" + onConfirmClick={() => { + handleDeleteDocument(document); + setDidDeleteOrMark(true); + }} + /> + + ) : ( + "" + )} + +
    +
    + ))} +
+
+ + +
{ + dropHandler(event, handleImageUpload); + setIsDragging(false); + }} + onDragOver={(event) => { + event.preventDefault(); + }} + onDragEnter={(event) => { + event.preventDefault(); + setIsDragging(true); + if (dropZoneRef.current?.contains(event.relatedTarget as Node)) { + setIsDragging(true); + } + }} + onDragLeave={(event) => { + event.preventDefault(); + if (!dropZoneRef.current?.contains(event.relatedTarget as Node)) { + setIsDragging(false); + } + }} + style={{ + backgroundImage: `url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='12' ry='12' stroke='%23${isDragging ? "006867" : "c4c3c2"}' stroke-width='4' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e")`, + borderRadius: "12px", + backgroundColor: isDragging ? "#f0f0f0" : "white", + }} + > + + + Plant Icon +

Select a file or drag and drop here

+

JPG, PNG or PDF, file size no more than 10MB

+ + {modalFiles.length > 0 && ( +
    + {modalFiles.map((document) => ( +
  • { + setModalFiles((prev) => prev.filter((file) => file.name !== document.name)); + }} + title={document.name} + className="rounded-4 flex w-fit cursor-pointer items-center gap-3 rounded-md border-[1px] border-solid border-[#929292] bg-[#ececec] px-4 py-2" + > + + + + +
  • + ))} +
)} - /> -
-
-

Varying Programs

- prog.type === "varying") - .slice(0, 2) - .map((program: Program) => program.abbreviation)} - defaultValue={data?.programs.map( - (program) => programsMap[program.programId].abbreviation, + {modalFiles.length > 0 ? ( +
+
+
+ { + setModalFiles([]); + }} + onSaveClick={uploadDocument} + primaryLabel="Upload" + /> +
+ ) : ( +
+
)} - /> -
-
+

+ + + {documentError} +

+
+ +
); } diff --git a/frontend/src/components/StudentForm/hooks/useHandleImageDocumentUpload.ts b/frontend/src/components/StudentForm/hooks/useHandleImageDocumentUpload.ts new file mode 100644 index 00000000..8cdb3751 --- /dev/null +++ b/frontend/src/components/StudentForm/hooks/useHandleImageDocumentUpload.ts @@ -0,0 +1,145 @@ +import { deleteObject, getDownloadURL, ref, uploadBytes } from "firebase/storage"; +import { Dispatch, SetStateAction } from "react"; + +import { Document } from "../types"; + +import { Student } from "@/api/students"; +import { editPhoto } from "@/api/user"; +import { storage } from "@/firebase/firebase"; + +type HandleImageDocumentUploadProps = { + imageDataProp: { + imageFormData: FormData | null; + newStudentId: string; + newImageId: string; + type: "add" | "edit"; + data: Student | null; + firebaseToken: string; + }; + documentDataProp: { + currentFiles: File[]; + studentId: string; + type: "add" | "edit"; + studentDocuments: Document[]; + setStudentDocuments: Dispatch>; + didDeleteOrMark: boolean; + previousDocuments: Document[] | undefined; + }; +}; + +export const useHandleImageDocumentUpload = ({ + imageDataProp, + documentDataProp, +}: HandleImageDocumentUploadProps) => { + const handleAddingNewImage = async () => { + const { imageFormData, newStudentId, newImageId, type, data, firebaseToken } = imageDataProp; + if (!imageFormData) return ""; + + let studentId = newStudentId; + let uploadType = "new"; + let previousImageId = "default"; + let imageId = newImageId; + + if (type === "edit" && data) { + studentId = data._id; + uploadType = "edit"; + previousImageId = data.profilePicture; + if (previousImageId !== "default") { + imageId = ""; + } + } + + const resultImageId = await Promise.resolve( + editPhoto( + imageFormData, + previousImageId, + studentId, + "student", + uploadType, + imageId, + firebaseToken, + ) + .then((result) => { + if (result.success) { + console.log("Successfully added photo"); + return result.data; + } else { + console.log("Error has occured", result.error); + return "default"; + } + }) + .catch((error) => { + console.error(error); + return "default"; + }), + ); + + return resultImageId; + }; + + const handleUploadingDocument = async () => { + const { currentFiles, studentId, type, studentDocuments, setStudentDocuments } = + documentDataProp; + + const uploadPromises = currentFiles.map(async (file) => { + const storageRef = ref(storage, `documents/${studentId}/` + file.name); + + return uploadBytes(storageRef, file) + .then(async (snapshot) => { + console.log(`Uploaded: ${file.name}`); + const downloadURL = await getDownloadURL(snapshot.ref); + return { link: downloadURL, name: file.name, markedAdmin: false }; + }) + .catch((error) => { + console.error("Error uploading file:", file.name, error); + throw error; + }); + }); + + // Use Promise.all to wait for all uploads to complete + let documentData = await Promise.all(uploadPromises); + + if (type === "add") { + setStudentDocuments(documentData); + } else { + const validDocuments = studentDocuments.filter((doc) => { + return doc.link !== ""; + }); + if (validDocuments.length > 0) { + documentData = [...validDocuments, ...documentData]; + } + + setStudentDocuments(documentData); + } + + return documentData; + }; + + const handleDidDeleteOrMark = () => { + const { studentId, studentDocuments, previousDocuments } = documentDataProp; + + const docsToBeDeleted: string[] = []; + const newDocumentNames = studentDocuments.map((doc) => doc.name); + + previousDocuments?.forEach((doc) => { + if (!newDocumentNames.includes(doc.name)) { + docsToBeDeleted.push(doc.name); + } + + docsToBeDeleted.forEach((fileName) => { + const deleteFileRef = ref(storage, `documents/${studentId}/` + fileName); + + deleteObject(deleteFileRef) + .then(() => { + console.log(`Deleted: ${fileName}`); + }) + .catch((error) => { + console.error("Error deleting file:", fileName, error); + throw error; + }); + }); + }); + }; + + return { handleAddingNewImage, handleUploadingDocument, handleDidDeleteOrMark }; +}; diff --git a/frontend/src/components/StudentForm/styles/StudentInfo.module.css b/frontend/src/components/StudentForm/styles/StudentInfo.module.css new file mode 100644 index 00000000..112449b2 --- /dev/null +++ b/frontend/src/components/StudentForm/styles/StudentInfo.module.css @@ -0,0 +1,4 @@ +.document_dropzone { + /* background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='12' ry='12' stroke='%23bdbdbd' stroke-width='3' stroke-dasharray='6%2c 14' stroke-dashoffset='0' stroke-linecap='square'/%3e%3c/svg%3e"); + border-radius: 12px; */ +} diff --git a/frontend/src/components/StudentForm/types.ts b/frontend/src/components/StudentForm/types.ts index 9bc1f504..775ae996 100644 --- a/frontend/src/components/StudentForm/types.ts +++ b/frontend/src/components/StudentForm/types.ts @@ -12,7 +12,14 @@ export type ProgramLink = { hoursLeft: number; }; +export type Document = { + name: string; + link: string; + markedAdmin: boolean; +}; + export type StudentData = { + _id?: string; student: Contact; emergency: Contact; serviceCoordinator: Contact; @@ -21,31 +28,74 @@ export type StudentData = { birthday: Date; intakeDate: Date; tourDate: Date; - programs: ProgramLink[]; - dietary: string[]; - otherString: string; + conservation: boolean; + UCINumber: string; + incidentForm: string; + documents: Document[]; + profilePicture: string; + enrollments: Enrollment[]; + dietary?: string[]; }; export type StudentFormData = { - student_name: string; - student_last: string; - student_email: string; - student_phone: string; - emergency_name: string; - emergency_last: string; - emergency_email: string; - emergency_phone: string; - serviceCoordinator_name: string; - serviceCoordinator_last: string; - serviceCoordinator_email: string; - serviceCoordinator_phone: string; + studentName: string; + studentLast: string; + studentEmail: string; + studentPhone: string; + emergencyName: string; + emergencyLast: string; + emergencyEmail: string; + emergencyPhone: string; + serviceCoordinatorName: string; + serviceCoordinatorLast: string; + serviceCoordinatorEmail: string; + serviceCoordinatorPhone: string; address: string; birthdate: Date; medication: string; - dietary: string[]; other: string; - intake_date: Date; - tour_date: Date; - regular_programs: string[]; - varying_programs: string[]; + intakeDate: Date; + tourDate: Date; + conservation: string; + UCINumber: string; + incidentForm: string; + documents: Document[]; + profilePicture: string; + regularEnrollments: EnrollmentFormEntry[]; + varyingEnrollments: EnrollmentFormEntry[]; +}; + +export type Enrollment = { + studentId: string; + programId: string; + status: string; + dateUpdated: Date; + hoursLeft: number; + schedule: string[]; + sessionTime: { + start_time: string; + end_time: string; + }; + startDate: Date; + renewalDate: Date; + authNumber: string; }; + +export type EnrollmentFormEntry = Omit & { + startDate: string; + renewalDate: string; + varying: boolean; +}; + +// get [XX:YY, ZZ:AA] -> form interprets starting time and uses string +// XX:YY AM - ZZ:AA PM +// when we modify the session time on the form we update the resulting string +// make request to backed with [XX:YY, ZZ:AA] as the session time + +export enum StatusOptions { + Joined = "Joined", + Waitlisted = "Waitlisted", + Archived = "Archived", + NotAFit = "Not a fit", + Completed = "Completed", +} diff --git a/frontend/src/components/StudentFormButton.tsx b/frontend/src/components/StudentFormButton.tsx deleted file mode 100644 index 870e9cea..00000000 --- a/frontend/src/components/StudentFormButton.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import Image from "next/image"; -import { Dispatch, SetStateAction, useContext, useState } from "react"; -import { SubmitHandler, useForm } from "react-hook-form"; - -import PlusIcon from "../../public/icons/plus.svg"; -import { Student, createStudent, editStudent } from "../api/students"; -import { cn } from "../lib/utils"; - -import { Button } from "./Button"; -import ContactInfo from "./StudentForm/ContactInfo"; -import StudentBackground from "./StudentForm/StudentBackground"; -import StudentInfo from "./StudentForm/StudentInfo"; -import { StudentData, StudentFormData } from "./StudentForm/types"; -import { StudentMap } from "./StudentsTable/types"; -import { Dialog, DialogClose, DialogContent, DialogTrigger } from "./ui/dialog"; - -import { ProgramsContext } from "@/contexts/program"; -import { UserContext } from "@/contexts/user"; - -type BaseProps = { - classname?: string; - setAllStudents: Dispatch>; -}; - -type EditProps = BaseProps & { - type: "edit"; - data: Student | null; -}; - -type AddProps = BaseProps & { - type: "add"; - data?: Student | null; -}; - -type StudentFormProps = EditProps | AddProps; - -export default function StudentFormButton({ - type, - data = null, //Student data so form can be populated - setAllStudents, //Update state of allStudents after creating or editing student - classname, -}: StudentFormProps) { - const { - register, - setValue: setCalendarValue, - reset, - handleSubmit, - } = useForm({ - defaultValues: { varying_programs: [], regular_programs: [], dietary: [] }, - }); - //Default values can be set for all fields but I specified these three fields because the checkbox value can sometimes be a string if it's a single value rather than array of strings. https://github.com/react-hook-form/react-hook-form/releases/tag/v7.30.0 - - const [openForm, setOpenForm] = useState(false); - const { allPrograms } = useContext(ProgramsContext); - const { isAdmin } = useContext(UserContext); - - const onFormSubmit: SubmitHandler = (formData: StudentFormData) => { - const programAbbreviationToId = {} as Record; // abbreviation -> programId - Object.values(allPrograms).forEach( - (program) => (programAbbreviationToId[program.abbreviation] = program._id), - ); - - const transformedData: StudentData = { - student: { - firstName: formData.student_name, - lastName: formData.student_last, - email: formData.student_email, - phoneNumber: formData.student_phone, - }, - emergency: { - firstName: formData.emergency_name, - lastName: formData.emergency_last, - email: formData.emergency_email, - phoneNumber: formData.emergency_phone, - }, - serviceCoordinator: { - firstName: formData.serviceCoordinator_name, - lastName: formData.serviceCoordinator_last, - email: formData.serviceCoordinator_email, - phoneNumber: formData.serviceCoordinator_phone, - }, - location: formData.address, - medication: formData.medication, - birthday: new Date(formData.birthdate), - intakeDate: new Date(formData.intake_date), - tourDate: new Date(formData.tour_date), - programs: formData.regular_programs - .map((abbreviation) => ({ - programId: programAbbreviationToId[abbreviation], - status: "Joined", - dateUpdated: new Date(), - hoursLeft: 0, - })) - .concat( - formData.varying_programs.map((abbreviation) => ({ - programId: programAbbreviationToId[abbreviation], - status: "Joined", - dateUpdated: new Date(), - hoursLeft: 0, - })), - ), - dietary: formData.dietary, - otherString: formData.other, - }; - - console.log(transformedData); - - if (type === "add") { - createStudent(transformedData).then( - (result) => { - if (result.success) { - const newStudent = result.data; - reset(); // only clear form on success - setOpenForm(false); - console.log("Student created successfully"); - setAllStudents((prevStudents: StudentMap) => { - return { ...prevStudents, [newStudent._id]: { ...newStudent } }; - }); - console.log(newStudent); - } else { - console.log(result.error); - alert("Unable to create student: " + result.error); - } - }, - (error) => { - console.log(error); - }, - ); - } - - if (type === "edit" && data) { - const editedData: Student = { ...transformedData, _id: data._id }; - editStudent(editedData).then( - (result) => { - if (result.success) { - const editedStudent = result.data; - setOpenForm(false); - setAllStudents((prevStudents: StudentMap) => { - if (Object.keys(prevStudents).includes(editedStudent._id)) { - return { ...prevStudents, [editedStudent._id]: { ...editedStudent } }; - } else { - console.log("Student ID is invalid"); - alert("Student ID is invalid"); - return prevStudents; - } - }); - console.log(editedStudent); - } else { - console.log(result.error); - alert("Unable to edit student: " + result.error); - } - }, - (error) => { - console.log(error); - }, - ); - } - }; - - return ( - - - {type === "edit" ? ( - view student - ) : ( -
-
- - - ) : ( -
- - - - ); -} diff --git a/frontend/src/components/StudentProfile.tsx b/frontend/src/components/StudentProfile.tsx new file mode 100644 index 00000000..4e33487d --- /dev/null +++ b/frontend/src/components/StudentProfile.tsx @@ -0,0 +1,579 @@ +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { MouseEventHandler, useContext, useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useReactToPrint } from "react-to-print"; + +import { Enrollment } from "../api/programs"; +import { Student, deleteStudent, getStudent } from "../api/students"; +import { ProgramsContext } from "../contexts/program"; +import { UserContext } from "../contexts/user"; + +import LoadingSpinner from "./LoadingSpinner"; +import ModalConfirmation from "./Modals/ModalConfirmation"; +import StudentForm from "./StudentForm/StudentForm"; +import { Contact } from "./StudentForm/types"; +import StudentProfilePrintComponent from "./StudentProfilePrintComponent"; +import { Textfield } from "./Textfield"; + +import { getPhoto } from "@/api/user"; + +// Aggregate only the fields necessary for display on frontend +// to reduce confusion when managing programs/programlinks/enrollments +export type EnrollmentDisplayInfo = { + name: string; + type: string; + status: string; + abbreviation: string; + startDate: Date; + renewalDate: Date; + authNumber: string; + sessionTime: { + start_time: string; + end_time: string; + }; + schedule: string[]; +}; + +type StudentProfileProps = { + id: string; +}; + +type ContactLayoutProps = { + contact: Contact; + header: string; + children?: JSX.Element | JSX.Element[]; +}; + +type ProgramLayoutProps = { + enrollmentInfo: EnrollmentDisplayInfo; +}; + +function formatDate(d: Date) { + const date = new Date(d); + return date.getMonth() + 1 + "/" + date.getDate() + "/" + date.getFullYear(); +} + +function ArrowHome() { + return ( + + + + + + ); +} + +function ContactLayout({ contact, header, children }: ContactLayoutProps) { + return ( +
+
{header}:
+
+ Name: {contact.firstName + " " + contact.lastName} +
+
Email: {contact.email}
+
Phone: {contact.phoneNumber}
+ {children} +
+ ); +} + +function ProgramLayout({ enrollmentInfo }: ProgramLayoutProps) { + const regular = enrollmentInfo.type === "regular"; + return ( + <> +
+
{enrollmentInfo.abbreviation} -
+ {(() => { + switch (enrollmentInfo.status) { + case "Joined": + return
Joined
; + case "Waitlisted": + return
Waitlist
; + case "Archived": + return
Archived
; + case "Not a fit": + return
Not a Fit
; + default: + return <>; + } + })()} +
+
+ Start Date: {formatDate(enrollmentInfo.startDate)} +
+
+ End Date: {formatDate(enrollmentInfo.renewalDate)} +
+
+ Authorization Code: {enrollmentInfo.authNumber}{" "} +
+ {regular && ( +
+ Session Time: + {" " + + enrollmentInfo.sessionTime.start_time + + " - " + + enrollmentInfo.sessionTime.end_time} +
+ )} + {regular && ( + <> +
Days of the Week
+
+ {["M", "T", "W", "Th", "F", "Sa", "Su"].map((value) => { + if ( + enrollmentInfo.schedule.find((day) => { + return day === value; + }) + ) + return ( +
+
{value}
+
+ ); + return ( +
+
{value}
+
+ ); + })} +
+ + )} + + ); +} + +export default function StudentProfile({ id }: StudentProfileProps) { + const [currentView, setCurrentView] = useState<"View" | "Edit">("View"); + const { firebaseUser, isAdmin } = useContext(UserContext); + const [firebaseToken, setFirebaseToken] = useState(); + const [notFound, setNotFound] = useState(false); + const [studentData, setStudentData] = useState(); + const [enrollmentInfo, setEnrollmentInfo] = useState(); + const [image, setImage] = useState(""); + const { allPrograms } = useContext(ProgramsContext); + const contentRef = useRef(null); + const reactToPrintFn = useReactToPrint({ contentRef }); + + const { register: deleteRegister, getValues: getDeleteValue } = useForm<{ lastname: string }>(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const router = useRouter(); + + const handleViewChange = () => { + setCurrentView(currentView === "View" ? "Edit" : "View"); + }; + + const TruncateDocument = ({ + documentName, + documentLength, + }: { + documentName: string; + documentLength: number; + }) => { + const minLength = 9; // Shortest truncation + const maxLength = 20; // Longest truncation + const baseName = documentName.slice(0, documentName.lastIndexOf(".")); + + // Use an inverse relationship: fewer documents = longer names + const dynamicLength = Math.max( + minLength, + Math.min(maxLength, 20 - Math.floor((documentLength - 1) * 2)), + ); + + // Only truncate and add ellipsis if the basename is longer than dynamicLength + const displayName = + baseName.length > dynamicLength ? baseName.substring(0, dynamicLength) + "..." : baseName; + + return displayName; + }; + + const deleteStudentHandler: MouseEventHandler = () => { + const lastName = getDeleteValue("lastname"); + if (studentData && firebaseToken && studentData.student.lastName === lastName) { + deleteStudent(studentData._id, firebaseToken) + .then((result) => { + if (result.success) { + //console.log("Deletion Successful"); + router.push("/home"); + } else console.log(result.error); + }) + .catch((error) => { + console.log(error); + }); + } else alert("Please enter the student's last name (case-sensitive)"); + }; + + useEffect(() => { + if (firebaseToken) { + getStudent(id, firebaseToken) + .then((result) => { + if (result.success) { + const studentResult = result.data; + setStudentData(studentResult); + } else { + setNotFound(true); + } + }) + .catch((err) => { + console.log(err); + }); + } + }, [firebaseToken]); + + // Get student image + useEffect(() => { + if (!studentData || !firebaseToken) return; + if (studentData.profilePicture === "default") { + setImage("default"); + return; + } + getPhoto(studentData.profilePicture, studentData._id, "student", firebaseToken).then( + (photoResult) => { + if (photoResult.success) { + const newImage = photoResult.data; + setImage(newImage); + } else { + console.error(photoResult.error); + } + }, + (error) => { + console.error(error); + }, + ); + }, [studentData]); + + useEffect(() => { + if (studentData) { + setEnrollmentInfo( + studentData.enrollments.map((value) => { + return { + ...allPrograms[value.programId], + ...(value as unknown as Enrollment), + }; + }), + ); + } + }, [studentData]); + + useEffect(() => { + if (firebaseUser) { + firebaseUser + .getIdToken() + .then((token) => { + setFirebaseToken(token); + }) + .catch((error) => { + console.log(error); + }); + } + }, [firebaseUser]); + + if (notFound) { + return ( +
+ +
Student Not Found
+
+ ); + } + return ( + studentData && + enrollmentInfo && ( +
+
+ + {/*no need to set all students*/} + +
+ + {currentView === "View" ? ( + <> +
+ + {/*profile picture*/} + {image !== "" ? ( +
+ Profile Picture +
+ ) : ( + + )} +
+
+ +
+
Conserved:
+ {studentData.conservation ? ( +
Yes
+ ) : ( +
No
+ )} +
+
+ +
+ +
+
+
Student Background:
+
Address: {studentData.location}
+
+ Birthdate: {formatDate(studentData.birthday)} +
+
+
+
Student Information:
+
+ Intake Date: {formatDate(studentData.intakeDate)} +
+
+ Tour Date: {formatDate(studentData.tourDate)} +
+
+
+
+
+
Documents
+
+ {studentData.documents?.map((doc, index) => ( + + ))} +
+
+
+
Medication & Medical
+
Dietary Restrictions:
+
+ {studentData.dietary?.map((value) => ( +
+ {value} +
+
+ ))} +
+
+ Medication: {studentData.medication} +
+
+
+
+
+
Regular Programs:
+ {enrollmentInfo.map((value, index) => { + if (value.type === "regular") + return ; + return <>; + })} +
+
+
Varying Programs:
+ {enrollmentInfo.map((value, index) => { + if (value.type === "varying") + return ; + return <>; + })} +
+
+ {/*
+
Notifications
+
+ Review information of new account creations below to approve or deny them.{" "} +
+ +
*/} + +
+ {isAdmin && ( + +
+
+
  • This cannot be undone!
  • +
  • + This will remove this student from all enrolled programs and delete all + notes and documents. +
  • +
    +
    +
    + Enter the student's last name to proceed + +
    + + } + kind="destructive" + triggerElement={ + + } + confirmText="Delete" + icon={
    } + isParentOpen={deleteDialogOpen} + setIsParentOpen={setDeleteDialogOpen} + onConfirmClick={deleteStudentHandler} + /> + )} + +
    + +
    +
    + + ) : ( + + )} +
    + ) + ); +} diff --git a/frontend/src/components/StudentProfilePrintComponent.tsx b/frontend/src/components/StudentProfilePrintComponent.tsx new file mode 100644 index 00000000..a05f25d4 --- /dev/null +++ b/frontend/src/components/StudentProfilePrintComponent.tsx @@ -0,0 +1,110 @@ +import { LegacyRef } from "react"; + +import { Student } from "../api/students"; + +import { EnrollmentDisplayInfo } from "./StudentProfile"; + +type PrintComponentProps = { + data: Student; + contentRef: LegacyRef | undefined; + enrollments: EnrollmentDisplayInfo[]; +}; + +type PrintContactProps = { + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + header: string; +}; +type PrintProgramProps = { + enrollmentInfo: EnrollmentDisplayInfo; +}; + +function formatDate(d: Date) { + const date = new Date(d); + return date.getMonth() + 1 + "/" + date.getDate() + "/" + date.getFullYear(); +} + +function Contact({ firstName, lastName, email, phoneNumber, header }: PrintContactProps) { + return ( +
    +

    {header}

    +

    {firstName + " " + lastName}

    +

    {email}

    +

    {phoneNumber}

    +
    + ); +} + +function Program({ enrollmentInfo }: PrintProgramProps) { + const regular = enrollmentInfo.type === "regular"; + return ( +
    +
    {enrollmentInfo.name}
    +
    {enrollmentInfo.abbreviation}
    +
    Status: {enrollmentInfo.status}
    +
    Start Date: {formatDate(enrollmentInfo.startDate)}
    +
    End Date: {formatDate(enrollmentInfo.renewalDate)}
    +
    Authorization Code: {enrollmentInfo.authNumber}
    + {regular && ( +
    + Session Time: + {" " + + enrollmentInfo.sessionTime.start_time + + " - " + + enrollmentInfo.sessionTime.end_time} +
    + )} + {regular &&
    Days of the Week: {enrollmentInfo.schedule.join(", ")}
    } +
    + ); +} + +export default function StudentProfilePrintComponent({ + data, + contentRef, + enrollments, +}: PrintComponentProps) { + return ( +
    +
    + + + +
    +
    Student Background:
    +
    Address: {data.location}
    +
    Birthdate: {formatDate(data.birthday)}
    +
    Student Information:
    +
    Intake Date: {formatDate(data.intakeDate)}
    +
    Tour Date: {formatDate(data.tourDate)}
    +
    Medication & Medical
    +
    Dietary Restrictions:
    +
    + {data.dietary?.map((value) =>
  • {value}
  • )} +
    +
    Medication: {data.medication}
    +
    +
    +
    Regular Programs
    + {enrollments.map((value, index) => { + if (value.type === "regular") { + return ; + } + return <>; + })} +
    +
    +
    Varying Programs
    + {enrollments.map((value, index) => { + if (value.type === "varying") { + return ; + } + return <>; + })} +
    +
    +
    + ); +} diff --git a/frontend/src/components/StudentsTable/FilterFns.tsx b/frontend/src/components/StudentsTable/FilterFns.tsx index b625737f..f9d42930 100644 --- a/frontend/src/components/StudentsTable/FilterFns.tsx +++ b/frontend/src/components/StudentsTable/FilterFns.tsx @@ -64,7 +64,6 @@ export function ProgramFilter({ return ( }) { return ( ({}); - const [isLoading, setIsLoading] = useState(true); + const [currentView, setCurrentView] = useState<"View" | "Edit">("View"); + const { allStudents, setAllStudents } = useContext(StudentsContext); const [studentTable, setStudentTable] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); + const [isLoading, setIsLoading] = useState(true); const { isTablet } = useWindowSize(); const { allPrograms } = useContext(ProgramsContext); const { isAdmin } = useContext(UserContext); useEffect(() => { - getAllStudents().then( - (result) => { - console.log(result); - if (result.success) { - // Convert student array to object with keys as ids and values as corresponding student - const studentsObject = result.data.reduce((obj, student) => { - obj[student._id] = student; - return obj; - }, {} as StudentMap); - - setAllStudents(studentsObject); - setIsLoading(false); - } else { - console.log(result.error); - } - }, - (error) => { - console.log(error); - }, - ); - }, []); + if (allStudents) { + setIsLoading(false); + } + }, [allStudents]); useEffect(() => { + if (!allStudents) { + return; + } const studentsInformation: StudentTableRow[] = Object.values(allStudents).map((studentObj) => { return { id: studentObj._id, student: studentObj.student.firstName + " " + studentObj.student.lastName, emergencyContact: studentObj.emergency, - programs: studentObj.programs, + enrollments: studentObj.enrollments, } as StudentTableRow; }); setStudentTable(studentsInformation); @@ -98,29 +87,63 @@ export default function StudentsTable() { return (
    -
    -

    - Students -

    - {isAdmin && } -
    - {isLoading ? ( - + {currentView === "Edit" ? ( +
    +
    + { + setCurrentView("View"); + }} + className="cursor-pointer" + width="25" + height="20" + viewBox="0 0 10 8" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + +
    + +
    ) : ( - +
    +

    + Students +

    + {isAdmin && ( +
    + {isLoading ? ( + + ) : ( +
    + + +
    )} - > - - - + )}
    ); diff --git a/frontend/src/components/StudentsTable/THead.tsx b/frontend/src/components/StudentsTable/THead.tsx index e7258894..08a166ed 100644 --- a/frontend/src/components/StudentsTable/THead.tsx +++ b/frontend/src/components/StudentsTable/THead.tsx @@ -18,7 +18,7 @@ function TableActionsHeader({ setGlobalFilter: React.Dispatch>; }) { return ( - +
    @@ -49,7 +49,7 @@ function TableActionsHeader({ function TableDataHeader({ headerGroup }: { headerGroup: HeaderGroup }) { return ( - + {headerGroup.headers.map((header) => ( - {table.getHeaderGroups().map((headerGroup) => ( - + {table.getHeaderGroups().map((headerGroup, i) => ( + []; +export type Columns = ColumnDef[]; export type StudentMap = Record; export type ProgramMap = Record; diff --git a/frontend/src/components/StudentsTable/useColumnSchema.tsx b/frontend/src/components/StudentsTable/useColumnSchema.tsx index 36607062..745175ce 100644 --- a/frontend/src/components/StudentsTable/useColumnSchema.tsx +++ b/frontend/src/components/StudentsTable/useColumnSchema.tsx @@ -1,9 +1,10 @@ import { Poppins } from "next/font/google"; +import Image from "next/image"; +import Link from "next/link"; import { useMemo } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "../../components/ui/popover"; -import { Contact, ProgramLink } from "../StudentForm/types"; -import StudentFormButton from "../StudentFormButton"; +import { Contact, Enrollment, ProgramLink } from "../StudentForm/types"; import { Columns, ProgramMap, StudentMap } from "./types"; @@ -65,28 +66,30 @@ const ProgramPopover = ({ link, program }: { link: ProgramLink; program: Program }; const ProgramHistoryPopover = ({ - programs, + enrollments, allPrograms, }: { - programs: ProgramLink[]; + enrollments: Enrollment[]; allPrograms: ProgramMap; }) => { return ( - {programs.length + " programs"} + + {enrollments.length + " programs"} +
    Program History
    - {programs.map((program, i) => { - const progInfo = allPrograms[program.programId]; + {enrollments.map((enrollment, i) => { + const progInfo = allPrograms[enrollment.programId]; return ( progInfo && (
    - {program.status} + {enrollment.status} - {" on " + new Date(program.dateUpdated).toLocaleDateString("en-US")} + {" on " + new Date(enrollment.dateUpdated).toLocaleDateString("en-US")}
    @@ -104,9 +107,9 @@ export function useColumnSchema({ allPrograms, setAllStudents, }: { - allStudents: StudentMap; + allStudents: StudentMap | undefined; allPrograms: ProgramMap; - setAllStudents: React.Dispatch>; + setAllStudents: React.Dispatch>; }) { const { isTablet } = useWindowSize(); @@ -120,12 +123,12 @@ export function useColumnSchema({ enableColumnFilter: false, }, { - accessorKey: "programs", + accessorKey: "enrollments", id: isTablet ? "Curr. P1" : "Curr. Program 1", header: isTablet ? "Curr. P1" : "Curr. Program 1", cell: (info) => { - const programs = info.getValue() as unknown as ProgramLink[]; - const link = programs.filter((prog) => prog.status === "Joined")[0]; + const enrollments = info.getValue() as unknown as ProgramLink[]; + const link = enrollments.filter((enr) => enr.status === "Joined")[0]; if (!link) return null; const program = allPrograms[link.programId]; return ; @@ -133,13 +136,12 @@ export function useColumnSchema({ filterFn: "programFilter", }, { - accessorKey: "programs", + accessorKey: "enrollments", id: isTablet ? "Curr. P2" : "Curr. Program 2", header: isTablet ? "Curr. P2" : "Curr. Program 2", cell: (info) => { - const programs = info.getValue() as unknown as ProgramLink[]; - // programs.map((prog) => console.log(allPrograms[prog.programId].abbreviation)); - const link = programs.filter((prog) => prog.status === "Joined")[1]; + const enrollments = info.getValue() as unknown as ProgramLink[]; + const link = enrollments.filter((enr) => enr.status === "Joined")[1]; if (!link) return null; const program = allPrograms[link.programId]; return ; @@ -147,12 +149,15 @@ export function useColumnSchema({ filterFn: "statusFilter", }, { - id: "programs", + id: "enrollments", header: "Program History", - accessorFn: (row) => row.programs.length.toString(), + accessorFn: (row) => row.enrollments.length.toString(), cell: (info) => { return ( - + ); }, enableColumnFilter: false, @@ -178,11 +183,15 @@ export function useColumnSchema({ cell: (info) => { return (
    - + + view student +
    ); }, diff --git a/frontend/src/components/Textfield.tsx b/frontend/src/components/Textfield.tsx index f19c4e8c..38ad9561 100644 --- a/frontend/src/components/Textfield.tsx +++ b/frontend/src/components/Textfield.tsx @@ -76,14 +76,14 @@ export function Textfield({
    {icon ? {icon} : null} , registerOptions)} - className="focus-visible:out w-full appearance-none bg-inherit px-2 placeholder-pia_accent outline-none" + className="focus-visible:out w-full appearance-none px-2 placeholder-pia_accent outline-none" id={name + label + placeholder} type={type} disabled={disabled} diff --git a/frontend/src/contexts/program.tsx b/frontend/src/contexts/program.tsx index 18f1a626..7b554a48 100644 --- a/frontend/src/contexts/program.tsx +++ b/frontend/src/contexts/program.tsx @@ -1,4 +1,6 @@ -import React, { ReactNode, createContext, useEffect, useState } from "react"; +import React, { ReactNode, createContext, useContext, useEffect, useState } from "react"; + +import { UserContext } from "./user"; import { Program, getAllPrograms } from "@/api/programs"; import { ProgramMap } from "@/components/StudentsTable/types"; @@ -18,28 +20,45 @@ export const ProgramsContext = createContext({ export const ProgramsContextProvider = ({ children }: { children: ReactNode }) => { const [allPrograms, setAllPrograms] = useState({}); const [isLoading, setIsLoading] = useState(true); + const [firebaseToken, setFirebaseToken] = useState(); + const { firebaseUser } = useContext(UserContext); + + useEffect(() => { + if (firebaseUser) { + firebaseUser + .getIdToken() + .then((token) => { + setFirebaseToken(token); + }) + .catch((err) => { + console.log(err); + }); + } + }, [firebaseUser]); useEffect(() => { - getAllPrograms().then( - (result) => { - if (result.success) { - const programsObject = result.data.reduce( - (obj, program) => { - obj[program._id] = program; - return obj; - }, - {} as Record, - ); - setAllPrograms(programsObject); + if (firebaseToken) { + getAllPrograms(firebaseToken).then( + (result) => { + if (result.success) { + const programsObject = result.data.reduce( + (obj, program) => { + obj[program._id] = program; + return obj; + }, + {} as Record, + ); + setAllPrograms(programsObject); + setIsLoading(false); + } + }, + (error) => { + console.log(error); setIsLoading(false); - } - }, - (error) => { - console.log(error); - setIsLoading(false); - }, - ); - }, []); + }, + ); + } + }, [firebaseToken]); return ( diff --git a/frontend/src/contexts/students.tsx b/frontend/src/contexts/students.tsx new file mode 100644 index 00000000..bc70c335 --- /dev/null +++ b/frontend/src/contexts/students.tsx @@ -0,0 +1,62 @@ +import React, { ReactNode, createContext, useContext, useEffect, useState } from "react"; + +import { UserContext } from "./user"; + +import { Student, getAllStudents } from "@/api/students"; +import { StudentMap } from "@/components/StudentsTable/types"; + +type StudentsContext = { + allStudents: StudentMap | undefined; + isLoading: boolean; + setAllStudents: React.Dispatch>; +}; + +export const StudentsContext = createContext({ + allStudents: undefined, + setAllStudents: () => {}, + isLoading: true, +}); + +export const StudentsContextProvider = ({ children }: { children: ReactNode }) => { + const [allStudents, setAllStudents] = useState(undefined); + const [isLoading, setIsLoading] = useState(true); + const { firebaseUser } = useContext(UserContext); + + // Fetch Progress Notes and Students + useEffect(() => { + if (firebaseUser) { + firebaseUser + ?.getIdToken() + .then((token) => { + getAllStudents(token).then( + (result) => { + if (result.success) { + const StudentsObject = result.data.reduce( + (obj, student) => { + obj[student._id] = student; + return obj; + }, + {} as Record, + ); + setAllStudents(StudentsObject); + setIsLoading(false); + } + }, + (error) => { + console.log(error); + setIsLoading(false); + }, + ); + }) + .catch((error) => { + console.error(error); + }); + } + }, [firebaseUser]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/firebase/firebase.ts b/frontend/src/firebase/firebase.ts index 3a2af728..907e6ec4 100644 --- a/frontend/src/firebase/firebase.ts +++ b/frontend/src/firebase/firebase.ts @@ -2,6 +2,7 @@ import dotenv from "dotenv"; import { FirebaseOptions, initializeApp } from "firebase/app"; // import { fetchSignInMethodsForEmail, getAuth } from "firebase/auth"; import { getAuth } from "firebase/auth"; +import { getStorage } from "firebase/storage"; dotenv.config(); @@ -15,10 +16,12 @@ export const initFirebase = () => { const app = initializeApp(firebaseConfig); const auth = getAuth(app); - return { app, auth }; + const storage = getStorage(app); + + return { app, auth, storage }; }; -const { app, auth } = initFirebase(); +const { app, auth, storage } = initFirebase(); // const checkEmailExists = async (email: string) => { // try { @@ -31,4 +34,4 @@ const { app, auth } = initFirebase(); // }; // export { app, auth, checkEmailExists }; -export { app, auth }; +export { app, auth, storage }; diff --git a/frontend/src/lib/camelCase.ts b/frontend/src/lib/camelCase.ts new file mode 100644 index 00000000..1b086c35 --- /dev/null +++ b/frontend/src/lib/camelCase.ts @@ -0,0 +1,7 @@ +export function camelize(str: string) { + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s+/g, ""); +} diff --git a/frontend/src/lib/sessionTimeParsing.ts b/frontend/src/lib/sessionTimeParsing.ts new file mode 100644 index 00000000..06ffc33d --- /dev/null +++ b/frontend/src/lib/sessionTimeParsing.ts @@ -0,0 +1,15 @@ +import { format, parse } from "date-fns"; +export const timeToAmPm = (time: string): string => { + if (!time) return ""; + const parsedTime = parse(time, "HH:mm", new Date()); + return format(parsedTime, "hh:mm a"); +}; + +// provide the reverse function of timeToAmPm - convert XX:YY AM - AA:BB PM to [XX:YY, AA:BB] +export const amPmToTime = (time: string): { start_time: string; end_time: string } => { + if (!time.includes("-")) return { start_time: "", end_time: "" }; + const [start, end] = time.split("-"); + const startTime = parse(start.trim(), "hh:mm a", new Date()); + const endTime = parse(end.trim(), "hh:mm a", new Date()); + return { start_time: format(startTime, "HH:mm"), end_time: format(endTime, "HH:mm") }; +}; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index d4342218..6de2657a 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -8,6 +8,7 @@ import Navigation from "@/components/Navigation"; // eslint-disable-next-line import/order import { NextPage } from "next"; import { ProgramsContextProvider } from "@/contexts/program"; +import { StudentsContextProvider } from "@/contexts/students"; import { UserContextProvider } from "@/contexts/user"; // import Navigation from "../components/Navigation"; @@ -24,7 +25,9 @@ function App({ Component, pageProps }: AppPropsWithLayout) { const getLayout = Component.getLayout ?? ((page) => {page}); return ( - {getLayout()} + + {getLayout()} + ); } diff --git a/frontend/src/pages/attendance.tsx b/frontend/src/pages/attendance.tsx index 72fad9dc..0cc37136 100644 --- a/frontend/src/pages/attendance.tsx +++ b/frontend/src/pages/attendance.tsx @@ -1,11 +1,14 @@ -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { Program, getAllPrograms } from "@/api/programs"; import { AbsenceSession, Session, getAbsenceSessions, getRecentSessions } from "@/api/sessions"; import { getAllStudents } from "@/api/students"; import { AttendanceCard } from "@/components/AttendanceCard"; import { AttendanceTable } from "@/components/AttendanceTable"; -import { ProgramMap, StudentMap } from "@/components/StudentsTable/types"; +import LoadingSpinner from "@/components/LoadingSpinner"; +import { ProgramMap } from "@/components/StudentsTable/types"; +import { StudentsContext } from "@/contexts/students"; +import { UserContext } from "@/contexts/user"; import { useRedirectTo404IfNotAdmin, useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; export type Sessions = [Session]; @@ -17,17 +20,34 @@ export default function AttendanceDashboard() { const [allSessions, setAllSessions] = useState(); // map from program id to program const [allPrograms, setAllPrograms] = useState({}); // map from program id to program - const [allStudents, setAllStudents] = useState({}); const [allAbsenceSessions, setAllAbsenceSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(true); const [programsLoading, setProgramsLoading] = useState(true); - const [studentsLoading, setStudentsLoading] = useState(true); + // const [studentsLoading, setStudentsLoading] = useState(true); const [absencsSessionsLoading, setAbsenceSessionsLoading] = useState(true); + const { allStudents } = useContext(StudentsContext); + const studentsLoading = allStudents === undefined; + const [remainingSessions, setRemainingSessions] = useState(0); const [remainingAbsenceSessions, setRemainingAbsenceSessions] = useState(0); const scrollRef = useRef(null); + const [firebaseToken, setFirebaseToken] = useState(""); + const { firebaseUser } = useContext(UserContext); + + useEffect(() => { + if (firebaseUser) { + firebaseUser + .getIdToken() + .then((token) => { + setFirebaseToken(token); + }) + .catch((error) => { + console.log(error); + }); + } + }, [firebaseUser]); useEffect(() => { if (sessionsLoading || studentsLoading || programsLoading || absencsSessionsLoading) { @@ -51,7 +71,7 @@ export default function AttendanceDashboard() { }, [sessionsLoading || studentsLoading || programsLoading || absencsSessionsLoading]); useEffect(() => { - getRecentSessions().then( + getRecentSessions(firebaseToken).then( (result) => { if (result.success) { console.log(result.data); @@ -64,10 +84,10 @@ export default function AttendanceDashboard() { console.log(error); }, ); - }, []); + }, [firebaseToken]); useEffect(() => { - getAllPrograms().then( + getAllPrograms(firebaseToken).then( (result) => { if (result.success) { const programsObject = result.data.reduce( @@ -85,30 +105,30 @@ export default function AttendanceDashboard() { console.log(error); }, ); - }, []); + }, [firebaseToken]); useEffect(() => { - getAllStudents().then( + getAllStudents(firebaseToken).then( (result) => { // console.log(result); if (result.success) { // Convert student array to object with keys as ids and values as corresponding student - const studentsObject = result.data.reduce((obj, student) => { - obj[student._id] = student; - return obj; - }, {} as StudentMap); - setAllStudents(studentsObject); - setStudentsLoading(false); + // const studentsObject = result.data.reduce((obj, student) => { + // obj[student._id] = student; + // return obj; + // }, {} as StudentMap); + // setAllStudents(studentsObject); + // setStudentsLoading(false); } }, (error) => { console.log(error); }, ); - }, []); + }, [firebaseToken]); useEffect(() => { - getAbsenceSessions().then( + getAbsenceSessions(firebaseToken).then( (result) => { if (result.success) { console.log(result.data); @@ -121,10 +141,10 @@ export default function AttendanceDashboard() { console.log(error); }, ); - }, []); + }, [firebaseToken]); if (sessionsLoading || studentsLoading || programsLoading || absencsSessionsLoading) - return

    Loading...

    ; + return ; else { return (
    @@ -147,6 +167,7 @@ export default function AttendanceDashboard() { student={student} key={i} setRemainingSessions={setRemainingAbsenceSessions} + firebaseToken={firebaseToken} /> ); })} @@ -183,6 +204,7 @@ export default function AttendanceDashboard() { session={session} students={allStudents} key={i} + firebaseToken={firebaseToken} /> ); })} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index ec6bcb75..9a81a25a 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,7 +1,6 @@ import { ReactElement } from "react"; import Login from "./login"; -// import CreateUser from "./create_user"; import Landing from "@/components/Landing"; diff --git a/frontend/src/pages/student/[id].tsx b/frontend/src/pages/student/[id].tsx new file mode 100644 index 00000000..b9e0906c --- /dev/null +++ b/frontend/src/pages/student/[id].tsx @@ -0,0 +1,23 @@ +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +import StudentProfile from "@/components/StudentProfile"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/redirect"; + +export default function Student() { + useRedirectToLoginIfNotSignedIn(); + const router = useRouter(); + const [studentID, setStudentID] = useState(); + + useEffect(() => { + const id = router.query.id as string; + setStudentID(id); + }); + + if (studentID !== undefined) + return ( +
    + +
    + ); +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 2d590a94..91bb4129 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -138,7 +138,13 @@ module.exports = { DEFAULT: "#F3F3F3", }, pia_primary_white: { - DEFAULT: "#FFF", + DEFAULT: "#FFFFFF", + }, + pia_secondary_green: { + DEFAULT: "#4FA197", + }, + pia_orange: { + DEFAULT: "#FF7A5E", }, secondary: { DEFAULT: "hsl(var(--secondary))",