From d68283adb4a2500c2fa4d18be9178c1af1c69198 Mon Sep 17 00:00:00 2001 From: cowsed Date: Thu, 23 Jul 2026 16:20:21 -0400 Subject: [PATCH 1/2] Info page for QR codes at machines --- client/src/AppRouter.tsx | 4 +- client/src/common/EquipmentTrainingModal.tsx | 15 -- .../equipment_pages/EquipmentUserInfo.tsx | 248 ++++++++++++++++++ client/src/queries/userQueries.ts | 5 + server/src/graphql/resolvers/usersResolver.ts | 18 ++ server/src/graphql/schemas/usersSchema.ts | 1 + 6 files changed, 275 insertions(+), 16 deletions(-) create mode 100644 client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx diff --git a/client/src/AppRouter.tsx b/client/src/AppRouter.tsx index 56c92a7c..0120dd84 100644 --- a/client/src/AppRouter.tsx +++ b/client/src/AppRouter.tsx @@ -50,6 +50,7 @@ import AdminHistoryPage from "./pages/site-settings/AdminHistoryPage"; import ThemeManagementPage from "./pages/site-settings/ThemeManagementPage"; import NewThemePage from "./pages/site-settings/NewThemePage"; import ManageThemePage from "./pages/site-settings/ManageThemePage"; +import EquipmentUserInfo from "./pages/makerspace_page/equipment_pages/EquipmentUserInfo"; function AppRoot() { return ( @@ -136,7 +137,8 @@ export const routes = [ { path: "/makerspace/:makerspaceID", element: }, { path: "/terms", element: }, { path: "/help", element: }, - + { path: "/makerspace/:makerspaceID/equipmentUserInfo/:equipmentID", element: }, + /* Routes that need to be protected by auth */ { element: , diff --git a/client/src/common/EquipmentTrainingModal.tsx b/client/src/common/EquipmentTrainingModal.tsx index 453a0d7d..e455781a 100644 --- a/client/src/common/EquipmentTrainingModal.tsx +++ b/client/src/common/EquipmentTrainingModal.tsx @@ -98,21 +98,6 @@ export default function EquipmentTrainingModal(props: EquipmentTrainingModalProp user.visitor ? Sign In to View Training Progress : - 0 && byExpiry[0].status === "Expiring Soon" - ? "warning" - : "success" - } - sx={{ - width: "95%", - height: "16px" - }} - /> { totalReqsComplete !== totalRequirements diff --git a/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx b/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx new file mode 100644 index 00000000..a9c01cdd --- /dev/null +++ b/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx @@ -0,0 +1,248 @@ +import PersonIcon from '@mui/icons-material/Person'; +import { useParams } from "react-router-dom"; +import { GET_EQUIPMENT_BY_ID } from "../../../queries/equipmentQueries"; +import { Alert, Button, CardActionArea, LinearProgress, Typography } from "@mui/material"; +import { useQuery } from "@apollo/client/react"; +import { Equipment } from "./ManageEquipmentPage"; +import { Stack } from "@mui/system"; +import { useCurrentUser } from "../../../common/CurrentUserProvider"; +import { ModuleStatus, moduleStatusMapper, TrainingModule } from "../../../common/TrainingModuleUtils"; +import { FullMakerspace, GET_MAKERSPACE_BY_ID } from "../../../queries/makerspaceQueries"; +import { GET_ROOM } from "../../../queries/roomQueries"; +import RequestWrapper from "../../../common/RequestWrapper"; +import Room from "../../../types/Room"; +import { ReactNode } from "react"; +import { IS_USER_WELCOMED } from "../../../queries/userQueries"; +import ModuleStatusRow from "../../../common/ModuleStatusRow"; +import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; +import CloseIcon from "@mui/icons-material/Close"; +import CheckIcon from '@mui/icons-material/Check'; +import { Link } from "react-router-dom"; + +export default function EquipmentUserInfo() { + const user = useCurrentUser(); + const isVisitor = user.visitor; + + + const { makerspaceID, equipmentID } = useParams<{ makerspaceID: string, equipmentID: string }>(); + const getEquipmentByIDResult = useQuery(GET_EQUIPMENT_BY_ID, { + variables: { + id: equipmentID, + }, + }); + const getMakerspaceResult = useQuery(GET_MAKERSPACE_BY_ID, { + variables: { + id: makerspaceID, + }, + }); + + const getRoomResult = useQuery(GET_ROOM, { + variables: { + id: getEquipmentByIDResult.data?.equipment?.room?.id ?? -1, + } + }); + const isWelcomedResult = useQuery(IS_USER_WELCOMED, { + variables: { + userID: user.id, + roomID: getEquipmentByIDResult?.data?.equipment?.room?.id ?? -1, + } + }); + + function unfinishedTrainingWarning(): ReactNode { + return + + + It looks like you haven't finished your trainings yet. + Scroll down to see what's left. + + + + } + function noInPersonAccessCheck(): ReactNode { + return + + + Almost there! Before you unlock the machine, you must take an in person access check. Talk to a staff member to start this process. + + + + } + function unwelcomedWarning(): ReactNode { + return + It looks like you haven't signed in today. Tap your card at the front desk to sign in to the space. + + } + function allGood(): ReactNode { + return + Qualifications complete! Talk to staff if you're still having issues. + + } + + + + function renderPage(isWelcomed: boolean | undefined, equipment: Equipment | undefined, room: Room | undefined, makerspace: FullMakerspace | undefined) { + + if (!equipment || !room || !makerspace) { + return Failed to load equipment requirements + } + + const hasApprovedAccessCheck: boolean = user.accessChecks.some((ac) => Number(ac.equipmentID) === Number(equipment.id) && ac.approved) + + const makerspaceStatuses: ModuleStatus[] = makerspace.trainingModules.map(moduleStatusMapper(user.passedModules, user.trainingHolds)); + const roomStatuses: ModuleStatus[] = room.trainingModules.map(moduleStatusMapper(user.passedModules, user.trainingHolds)); + const equipmentStatuses: ModuleStatus[] = equipment.trainingModules.map((obj) => moduleStatusMapper(user.passedModules, user.trainingHolds)(obj as TrainingModule)); + + const numMakerspaceTrainingsComplete: number = makerspaceStatuses.filter((module) => module.status === "Passed" || module.status === "Expiring Soon").length; + const numRoomTrainingsComplete: number = roomStatuses.filter((module) => module.status === "Passed" || module.status === "Expiring Soon").length; + const numEquipmentTrainingsComplete: number = equipmentStatuses.filter((module) => module.status === "Passed" || module.status === "Expiring Soon").length + + const byExpiry = [...makerspaceStatuses, ...roomStatuses, ...equipmentStatuses] + .filter((module) => module.status === "Expiring Soon" || module.status === "Passed") + .sort((a, b) => new Date(a.expirationDate).getTime() - new Date(b.expirationDate).getTime()); + + const totalRequirements = makerspaceStatuses.length + roomStatuses.length + equipmentStatuses.length + (equipment.requiresInPerson ? 1 : 0); + const totalReqsComplete = numMakerspaceTrainingsComplete + numRoomTrainingsComplete + numEquipmentTrainingsComplete + ((hasApprovedAccessCheck && equipment.requiresInPerson) ? 1 : 0); + + const percentComplete: number = Math.round(totalReqsComplete / totalRequirements * 100); + + + let warningMessage: () => ReactNode = unfinishedTrainingWarning; + const hasAllTrainings = (numMakerspaceTrainingsComplete + numRoomTrainingsComplete + numEquipmentTrainingsComplete) >= makerspaceStatuses.length + roomStatuses.length + equipmentStatuses.length; + if (!hasAllTrainings) { + warningMessage = unfinishedTrainingWarning; + } else if (!hasApprovedAccessCheck && equipment.requiresInPerson) { + warningMessage = noInPersonAccessCheck; + } else if (!isWelcomed && equipment.needsWelcome) { + warningMessage = unwelcomedWarning; + } else { + warningMessage = allGood; + } + + + return + {warningMessage()} + {equipment.name} + 0 && byExpiry[0].status === "Expiring Soon" + ? "warning" + : "success" + } + sx={{ + width: "95%", + height: "16px" + }} + /> + + { + totalReqsComplete !== totalRequirements + ? `Training ${percentComplete}% Complete` + : byExpiry.length > 0 && byExpiry[0].status === "Expiring Soon" + ? "Expiring Soon!" + : "Trainings Complete!" + } + + + + { + makerspace.trainingModules.length > 0 + ? + Makerspace Requirements + { + makerspaceStatuses.map((moduleStatus) => ) + } + + : null + } + { + room.trainingModules.length > 0 + ? + Area Requirements + { + roomStatuses.map((moduleStatus) => ) + } + + : null + } + { + (equipment.trainingModules.length > 0 || equipment.requiresInPerson) + ? + Equipment Requirements + { + equipmentStatuses.map((moduleStatus) => ) + } + { + equipment.requiresInPerson + ? window.open(equipment.signOffUrl, "_blank noopener noreferrer") : undefined} + disableRipple={equipment.signOffUrl === ""} + > + + {user.visitor ? ( + + ) : hasApprovedAccessCheck ? ( + + ) : ( + + )} + + { + equipment.signOffUrl !== "" + ? Staff Sign-Off + : Staff Sign-Off + } + { + (equipment.requiresInPerson && !hasApprovedAccessCheck) + ? Complete all other requirments before attempting sign-off! + : null + } + + + + : null + } + + : null + } + + + + } + + function renderVisitor(equipment: Equipment | undefined) { + if (!equipment) { + return Failed to load equipment information + } + return + {equipment.name} + To view equipment prerequisites, please log in. + + + + } + + return + {(getEquipmentByIDResult?.data?.equipment as Equipment)?.name ?? "Equipment Checklist"} + { + isVisitor + ? renderVisitor(getEquipmentByIDResult?.data?.equipment as Equipment) + : renderPage(isWelcomedResult?.data?.isUserWelcomed ? isWelcomedResult?.data?.isUserWelcomed : false, getEquipmentByIDResult?.data?.equipment as Equipment, getRoomResult.data?.room as Room, getMakerspaceResult.data?.makerspaceByID as FullMakerspace) + } + +} \ No newline at end of file diff --git a/client/src/queries/userQueries.ts b/client/src/queries/userQueries.ts index 376be5cd..b5de3a40 100644 --- a/client/src/queries/userQueries.ts +++ b/client/src/queries/userQueries.ts @@ -207,6 +207,11 @@ export const UPDATE_STUDENT_PROFILE = gql` } `; +export const IS_USER_WELCOMED = gql` + query IsUserWelcomed($userID: ID!, $roomID: ID!) { + isUserWelcomed(userID: $userID, roomID: $roomID) + } +` export const GET_CURRENT_USER = gql` query GetCurrentUser { diff --git a/server/src/graphql/resolvers/usersResolver.ts b/server/src/graphql/resolvers/usersResolver.ts index 4fbe34e0..013ce4ba 100644 --- a/server/src/graphql/resolvers/usersResolver.ts +++ b/server/src/graphql/resolvers/usersResolver.ts @@ -13,6 +13,7 @@ import { getActiveTrainingHoldsByUser } from "../../database/repositories/Traini import { getMakerspaceByID } from "../../database/repositories/Makerspaces/MakerspaceRespository.js"; import { EntityNotFound } from "../../EntityNotFound.js"; import { UserRow } from "../../database/knex/tables.js"; +import { User } from "../../database/models/users/User.js"; const UsersResolvers = { User: { @@ -147,6 +148,23 @@ const UsersResolvers = { return user; }, + /** + * Check if the target user has been welcomed today + * @param userID the user to check + * @param roomID the room to check + * @returns true if welcomed + */ + + isUserWelcomed: async ( + _parent: any, + args: { userID: string, roomID: string }, + { ifStaffOrSelf }: ApolloContext) => + ifStaffOrSelf(Number(args.userID), async () => { + const rawUser = await UserRepo.getUserByID(Number(args.userID)) + const fullUser = new User(rawUser) + return await fullUser.wasWelcomedToday(Number(args.roomID)); + }), + /** * Fetch the number of total users * @returns String JSON of {count: number} diff --git a/server/src/graphql/schemas/usersSchema.ts b/server/src/graphql/schemas/usersSchema.ts index e10222c1..79f13b5f 100644 --- a/server/src/graphql/schemas/usersSchema.ts +++ b/server/src/graphql/schemas/usersSchema.ts @@ -92,6 +92,7 @@ export const UsersTypeDefs = gql` usersLimit(searchText: String): [User] user(id: ID!): User currentUser: User + isUserWelcomed(userID: ID!, roomID: ID!): Boolean numUsers: Count userByUsernameorUID(value: String): User } From 0607845e8d748f29bcc428cb318dc872c38fe48b Mon Sep 17 00:00:00 2001 From: cowsed Date: Thu, 23 Jul 2026 16:23:08 -0400 Subject: [PATCH 2/2] undo stolen code from training modal --- client/src/common/EquipmentTrainingModal.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/src/common/EquipmentTrainingModal.tsx b/client/src/common/EquipmentTrainingModal.tsx index e455781a..453a0d7d 100644 --- a/client/src/common/EquipmentTrainingModal.tsx +++ b/client/src/common/EquipmentTrainingModal.tsx @@ -98,6 +98,21 @@ export default function EquipmentTrainingModal(props: EquipmentTrainingModalProp user.visitor ? Sign In to View Training Progress : + 0 && byExpiry[0].status === "Expiring Soon" + ? "warning" + : "success" + } + sx={{ + width: "95%", + height: "16px" + }} + /> { totalReqsComplete !== totalRequirements