diff --git a/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceCard.tsx b/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceCard.tsx index 445484dd..71644729 100644 --- a/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceCard.tsx +++ b/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceCard.tsx @@ -1,5 +1,5 @@ import { Autocomplete, Button, Card, Chip, IconButton, Link, MenuItem, Select, Stack, TextField, Typography } from "@mui/material"; -import { DELETE_EQUIPMENT_INSTANCE, EquipmentInstance, InstanceStatus, UPDATE_INSTANCE, UPDATE_INSTANCE_CONTROLLER_ASSIGNMENT, UPDATE_INSTANCE_HOBBS_TIME } from "../../../queries/equipmentInstanceQueries"; +import { DELETE_EQUIPMENT_INSTANCE, EquipmentInstance, formatHobbsTime, InstanceStatus, UPDATE_INSTANCE, UPDATE_INSTANCE_CONTROLLER_ASSIGNMENT, UPDATE_INSTANCE_HOBBS_TIME } from "../../../queries/equipmentInstanceQueries"; import ActionButton from "../../../common/ActionButton"; import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'; import { useMutation, useQuery } from "@apollo/client/react"; @@ -100,19 +100,6 @@ export default function EquipmentInstanceCard(props: EquipmentInstanceCardProps) />; } - function formatSeconds(allSeconds: number){ - const hours = Math.floor(allSeconds / 3600); - if (hours > 0){ - return (allSeconds / 3600).toFixed(2) + " hrs" - } - const minutes = Math.floor((allSeconds % 3600) / 60); - const seconds = allSeconds % 60; - if (minutes > 0){ - return (minutes + seconds / 50).toFixed(2) + " min" - } else { - return Math.floor(allSeconds) + "sec" - } - } function activeUserDisplay() { if (!currentAccessController) { @@ -165,7 +152,7 @@ export default function EquipmentInstanceCard(props: EquipmentInstanceCardProps) { !allowEdit - ? formatSeconds(props.instance.hobbsTime) + ? formatHobbsTime(props.instance.hobbsTime) : hobbsTimeSetField() } diff --git a/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceRow.tsx b/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceRow.tsx index 83c298dd..9256b127 100644 --- a/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceRow.tsx +++ b/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceRow.tsx @@ -1,5 +1,5 @@ import { Autocomplete, Button, Card, Chip, IconButton, Link, MenuItem, Select, Stack, TextField, Typography } from "@mui/material"; -import { DELETE_EQUIPMENT_INSTANCE, EquipmentInstance, InstanceStatus, UPDATE_INSTANCE, UPDATE_INSTANCE_CONTROLLER_ASSIGNMENT, UPDATE_INSTANCE_HOBBS_TIME } from "../../../queries/equipmentInstanceQueries"; +import { DELETE_EQUIPMENT_INSTANCE, EquipmentInstance, formatHobbsTime, InstanceStatus, UPDATE_INSTANCE, UPDATE_INSTANCE_CONTROLLER_ASSIGNMENT, UPDATE_INSTANCE_HOBBS_TIME } from "../../../queries/equipmentInstanceQueries"; import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'; import { useMutation, useQuery } from "@apollo/client/react"; import { useState } from "react"; @@ -99,20 +99,6 @@ export default function EquipmentInstanceRow(props: EquipmentInstanceRowProps) { />; } - function formatSeconds(allSeconds: number){ - const hours = Math.floor(allSeconds / 3600); - if (hours > 0){ - return (allSeconds / 3600).toFixed(2) + " hrs" - } - const minutes = Math.floor((allSeconds % 3600) / 60); - const seconds = allSeconds % 60; - if (minutes > 0){ - return (minutes + seconds / 50).toFixed(2) + " min" - } else { - return Math.floor(allSeconds) + "sec" - } - } - function activeUserDisplay() { if (!currentAccessController) { return No User; @@ -162,7 +148,7 @@ export default function EquipmentInstanceRow(props: EquipmentInstanceRowProps) { { !allowEdit - ? formatSeconds(props.instance.hobbsTime) + ? formatHobbsTime(props.instance.hobbsTime) : hobbsTimeSetField() } diff --git a/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx b/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx index a9c01cdd..099dca8a 100644 --- a/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx +++ b/client/src/pages/makerspace_page/equipment_pages/EquipmentUserInfo.tsx @@ -1,7 +1,7 @@ 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 { Alert, Button, CardActionArea, LinearProgress, Link, Typography } from "@mui/material"; import { useQuery } from "@apollo/client/react"; import { Equipment } from "./ManageEquipmentPage"; import { Stack } from "@mui/system"; @@ -11,13 +11,16 @@ import { FullMakerspace, GET_MAKERSPACE_BY_ID } from "../../../queries/makerspac import { GET_ROOM } from "../../../queries/roomQueries"; import RequestWrapper from "../../../common/RequestWrapper"; import Room from "../../../types/Room"; -import { ReactNode } from "react"; +import { ReactNode, useState } 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"; +import WarningIcon from '@mui/icons-material/Warning'; +import NewTicketModal from '../maintenance_pages/NewTicketModal'; +import { isStaffFor } from '../../../common/PrivilegeUtils'; + export default function EquipmentUserInfo() { const user = useCurrentUser(); @@ -25,6 +28,7 @@ export default function EquipmentUserInfo() { const { makerspaceID, equipmentID } = useParams<{ makerspaceID: string, equipmentID: string }>(); + const isStaffForHere = isStaffFor(user, Number(makerspaceID)); const getEquipmentByIDResult = useQuery(GET_EQUIPMENT_BY_ID, { variables: { id: equipmentID, @@ -48,6 +52,10 @@ export default function EquipmentUserInfo() { } }); + const getMakerspace = useQuery(GET_MAKERSPACE_BY_ID, { variables: { id: makerspaceID } }); + const [newTicketModal, setNewTicketModal] = useState(false); + + function unfinishedTrainingWarning(): ReactNode { return @@ -209,12 +217,24 @@ export default function EquipmentUserInfo() { : null } - - >Equipment Information + {isStaffForHere ? : undefined} + } @@ -234,7 +254,6 @@ export default function EquipmentUserInfo() { LOGIN - } return @@ -244,5 +263,7 @@ export default function EquipmentUserInfo() { ? 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) } + {getMakerspace?.data?.makerspaceByID ? setNewTicketModal(false)} makerspace={getMakerspace?.data?.makerspaceByID} /> : undefined} + } \ No newline at end of file diff --git a/client/src/pages/makerspace_page/equipment_pages/ManageEquipmentPage.tsx b/client/src/pages/makerspace_page/equipment_pages/ManageEquipmentPage.tsx index 78cd495b..c0969a46 100644 --- a/client/src/pages/makerspace_page/equipment_pages/ManageEquipmentPage.tsx +++ b/client/src/pages/makerspace_page/equipment_pages/ManageEquipmentPage.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from "@apollo/client/react"; -import { Button, Stack, TextField, Typography } from "@mui/material"; +import { Button, Link, Stack, TextField, Typography } from "@mui/material"; import { GET_EQUIPMENT_BY_ID } from "../../../queries/equipmentQueries"; import { useNavigate, useParams } from "react-router-dom"; import RequestWrapper2 from "../../../common/RequestWrapper2"; @@ -7,7 +7,7 @@ import EquipmentInformation from "./EquipmentInformation"; import PrettyModal from "../../../common/PrettyModal"; import InstanceGrid from "./InstanceGrid"; import { useIsMobile } from "../../../common/IsMobileProvider"; -import { useState } from "react"; +import { useRef, useState } from "react"; import AddIcon from '@mui/icons-material/Add'; import { CREATE_EQUIPMENT_INSTANCE } from "../../../queries/equipmentInstanceQueries"; import HistoryIcon from '@mui/icons-material/History'; @@ -18,6 +18,8 @@ import MaintenanceTicketGrid from "../maintenance_pages/MaintenanceTicketGrid"; import WarningIcon from '@mui/icons-material/Warning'; import NewTicketModal from "../maintenance_pages/NewTicketModal"; import { useMakeTheme } from "../../../common/MakeThemeProvider"; +import QrCodeIcon from '@mui/icons-material/QrCode'; +import { QRCode } from "react-qr-code"; export interface Equipment { id: number; @@ -51,6 +53,10 @@ export interface Equipment { } + + + + export default function ManageEquipmentPage() { const { makerspaceID, equipmentID } = useParams<{ makerspaceID: string, equipmentID: string }>(); const isMobile = useIsMobile(); @@ -58,6 +64,46 @@ export default function ManageEquipmentPage() { const user = useCurrentUser(); const makeTheme = useMakeTheme(); + const qrRef = useRef(null); + + function copyQRCode() { + try { + const svg = qrRef.current; + if (svg === null) { return; } + const svgData = new XMLSerializer().serializeToString(svg); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + console.log("qrRef", qrRef.current) + + const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + + img.onload = () => { + // Set canvas dimensions to match SVG + canvas.width = img.width; + canvas.height = img.height; + if (ctx === null) { return; } + ctx.drawImage(img, 0, 0); + + // Copy to clipboard as a PNG + canvas.toBlob((blob) => { + if (blob === null) { return; } + const item = new ClipboardItem({ "image/png": blob }); + navigator.clipboard.write([item]); + }); + + URL.revokeObjectURL(url); + }; + + img.src = url; + } catch (err) { + console.error("Failed to copy qr code", err) + } + } + + + const getEquipmentByIDResult = useQuery(GET_EQUIPMENT_BY_ID, { variables: { id: equipmentID, @@ -70,6 +116,7 @@ export default function ManageEquipmentPage() { const [newInstanceName, setNewInstanceName] = useState(""); const [newInstanceModal, setNewInstanceModal] = useState(false); + const [showQrCode, setShowQrCode] = useState(false); const [editEquipmentModal, setEditEquipmentModal] = useState(false); @@ -104,6 +151,19 @@ export default function ManageEquipmentPage() { > {`Manage ${equipment.name}`} + + + + + + + + + + + + + {/* Equipment Information Modal */} setEditEquipmentModal(false)} width={"90%"} elevation={8}> diff --git a/client/src/pages/makerspace_page/maintenance_pages/MaintenancePage.tsx b/client/src/pages/makerspace_page/maintenance_pages/MaintenancePage.tsx index 45cc5d1a..4f511371 100644 --- a/client/src/pages/makerspace_page/maintenance_pages/MaintenancePage.tsx +++ b/client/src/pages/makerspace_page/maintenance_pages/MaintenancePage.tsx @@ -60,15 +60,16 @@ export default function MaintenancePage() { // const containsOperator = getGridStringOperators().filter((operator) => operator.value === "contains"); const columns: GridColDef[] = [ - { field: "id", headerName: "ID", width: 10, filterable: false, resizable: false, hideable: false }, + { field: "id", headerName: "ID", width: 25, filterable: false, resizable: false, hideable: false }, { field: "equipment", headerName: "Equipment", width: 350, sortable: false, filterable: false, resizable: false }, { field: "instance", headerName: "Instance", width: 300, sortable: false, filterable: false, resizable: false }, { field: "type", headerName: "Type", width: 140, sortable: false, filterable: false, resizable: false }, { field: "status", headerName: "Status", width: 140, filterable: false, resizable: false }, - { field: "severity", headerName: "Severity", width: 140, filterable: false, resizable: false }, - { field: "creator", headerName: "Creator", width: 140, sortable: false, filterable: false, resizable: false }, - { field: "assigned", headerName: "Assigned", width: 140, sortable: false, filterable: false, resizable: false }, - { field: "dateCreated", headerName: "Created", width: 180, filterable: false, resizable: false }, + { field: "severity", headerName: "Severity", width: 120, filterable: false, resizable: false }, + { field: "creator", headerName: "Creator", width: 120, sortable: false, filterable: false, resizable: false }, + { field: "assigned", headerName: "Assigned", width: 120, sortable: false, filterable: false, resizable: false }, + { field: "timeUnit", headerName: "Time Unit", width: 140, sortable: false, filterable: false, resizable: false }, + { field: "dateCreated", headerName: "Create Date", width: 180, filterable: false, resizable: false }, { field: "manage", headerName: "Manage", width: 140, filterable: false, sortable: false, renderCell: (params: GridRenderCellParams) => ( @@ -96,6 +97,7 @@ export default function MaintenancePage() { severity: ticket.severity, creator: ticket.type === MaintenanceTicketType.REPORTED ? ticket.creator?.ritUsername ?? "" : "SERVER", assigned: ticket.assigned?.ritUsername ?? "UNASSIGNED", + timeUnit: ticket.timeUnit, dateCreated: formatter.format(new Date(Number(ticket.dateCreated))), manage: ticket } diff --git a/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx b/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx index 83871475..b9312ff4 100644 --- a/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx +++ b/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx @@ -1,6 +1,6 @@ import { Stack } from "@mui/system"; import PrettyModal from "../../../common/PrettyModal"; -import { ASSIGN_MAINTENANCE_TICKET, DELETE_MAINTENACE_TICKET, MaintenanceTicket, MaintenanceTicketSeverity, MaintenanceTicketStatus, MaintenanceTicketType, MODIFY_MAINTENANCE_TICKET_STATUS, UPDATE_MAINTENACE_TICKET } from "../../../queries/maintenanceTicketQueries"; +import { ASSIGN_MAINTENANCE_TICKET, DELETE_MAINTENACE_TICKET, MaintenanceTicket, MaintenanceTicketSeverity, MaintenanceTicketStatus, MaintenanceTicketTimeUnit, MaintenanceTicketType, MODIFY_MAINTENANCE_TICKET_STATUS, UPDATE_MAINTENACE_TICKET } from "../../../queries/maintenanceTicketQueries"; import { Autocomplete, Button, Chip, IconButton, TextField, Typography } from "@mui/material"; import { useState } from "react"; import CloseIcon from '@mui/icons-material/Close'; @@ -17,6 +17,7 @@ import { useParams } from "react-router-dom"; import { GET_VALID_STAFF } from "../../../queries/makerspaceQueries"; import { CurrentUser, useCurrentUser } from "../../../common/CurrentUserProvider"; import { isManager } from "../../../common/PrivilegeUtils"; +import { formatHobbsTime } from "../../../queries/equipmentInstanceQueries"; interface TicketModalProps { open: boolean, @@ -194,6 +195,19 @@ export default function MaintenanceTicketModal(props: TicketModalProps) { } } + function createTimeLabel(){ + const after = `Create After: ${props.ticket.intervalHours} hours` + const difference = props.ticket.instance.hobbsTime - props.ticket.hobbsTimeAtCreate + if (difference < 0){ + return `${after} (${formatHobbsTime(Math.abs(difference))} to go)` + } else { + return `${after} (${formatHobbsTime(difference)} ago)` + } + } + + const shouldShowHobbsAtOpen= props.ticket.timeUnit == MaintenanceTicketTimeUnit.USAGE || props.ticket.status != MaintenanceTicketStatus.UPCOMING; + const shouldShowHobbsAtClose= props.ticket.status == MaintenanceTicketStatus.CLOSED; + return ( @@ -214,7 +228,10 @@ export default function MaintenanceTicketModal(props: TicketModalProps) { {`Ticket #${props.ticket.id}`} - {`Created: ${formatter.format(Number(props.ticket.dateCreated))}`} + { props.ticket.timeUnit=== MaintenanceTicketTimeUnit.CALENDAR ? + {`Create Date: ${formatter.format(Number(props.ticket.dateCreated))}`} + : {createTimeLabel()} + } {`Reported by: ${props.ticket.type === MaintenanceTicketType.REPORTED ? props.ticket.creator?.ritUsername ?? "" : "SERVER"}`} @@ -317,6 +334,10 @@ export default function MaintenanceTicketModal(props: TicketModalProps) { } + + {shouldShowHobbsAtOpen ? Usage at Open: {formatHobbsTime(props.ticket.hobbsTimeAtCreate)} : undefined} + {shouldShowHobbsAtClose ? Usage at Close: {props.ticket.hobbsTimeAtClose ? formatHobbsTime(props.ticket.hobbsTimeAtClose) : "N/A"} : undefined} + { editing ? (); const equipmentInstancesResult = useQuery(GET_EQUIPMENT_INSTANCES, { variables: { equipmentID: equipment?.id ?? -1 } }); - const [createTicket] = useMutation(CREATE_INTERVAL_MAINTENANCE_TICKET, { refetchQueries: ["PaginatedMaintenanceTickets", "MaintenanceTickets"] }); + const [createTicket] = useMutation(CREATE_INTERVAL_MAINTENANCE_TICKET, { refetchQueries: [{query: PAGINATED_MAINTENANCE_TICKETS}, {query: GET_MAINTENANCE_TICKETS}] }); const EMPTY_ARRAY: EquipmentInstance[] = []; const instances: EquipmentInstance[] = equipmentInstancesResult.data?.equipmentInstances ?? EMPTY_ARRAY; @@ -50,7 +50,8 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { const makerspace_equipments = makerspace_equipments_2?.flat(1); const [startDate, setStartDate] = useState(new Date()); - const [scale, setScale] = useState("days"); + const [timeUnit, setTimeUnit] = useState(""); // USAGE | CALENDAR + const [scale, setScale] = useState("days"); // hours | days | weeks const [interval, setInterval] = useState("1"); function handleChangeScale(_event: React.MouseEvent, value: string) { @@ -58,6 +59,11 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { setScale(value); } } + function handleChangeTimeUnit(_event: React.MouseEvent, value: string) { + if (value !== null && value !== timeUnit) { + setTimeUnit(value); + } + } function handleClose() { setEquipment(undefined); @@ -67,17 +73,20 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { setImageUrl(undefined); setStartDate(new Date()); setScale("days"); + setTimeUnit("CALENDAR") setInterval("1"); props.onClose(); } async function handleCreateTicket() { - if (!(equipment && reportedInstance && severity && !Number.isNaN(Number(interval)))) { - toast.error("A required field is empty!"); + if (!(equipment && reportedInstance && severity && !Number.isNaN(Number(interval)) && (timeUnit == "USAGE" || timeUnit == "CALENDAR") ) ) { + toast.error("A required field is empty or invalid!"); return; } try { + const intervalHours = scale === "days" ? Math.floor(Number(interval) * 24) : (scale == "weeks") ? Math.floor(Number(interval) * 168) : Math.floor(Number(interval)) + await createTicket({ variables: { severity: severity, @@ -85,7 +94,8 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { description: description, startDate: startDate.toISOString(), imageUrl: imageUrl, - intervalHours: scale === "days" ? Number(interval) * 24 : Number(interval) * 168 + intervalHours: intervalHours, + timeUnit: timeUnit, } }) } catch (e) { @@ -127,6 +137,7 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { { equipment ? ( @@ -159,21 +170,40 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { disabled /> } - ( - - ) - } - options={[MaintenanceTicketSeverity.HIGH, MaintenanceTicketSeverity.MEDIUM, MaintenanceTicketSeverity.LOW]} - value={severity} - onChange={(event, newValue) => setSeverity(newValue ?? undefined)} - /> + + ( + + ) + } + options={[MaintenanceTicketSeverity.HIGH, MaintenanceTicketSeverity.MEDIUM, MaintenanceTicketSeverity.LOW]} + value={severity} + onChange={(event, newValue) => setSeverity(newValue ?? undefined)} + sx={{flexGrow: 1}} + /> + + + + Usage + + + Calendar + + + + + + + + + Hours + Days diff --git a/client/src/pages/makerspace_page/maintenance_pages/NewTicketModal.tsx b/client/src/pages/makerspace_page/maintenance_pages/NewTicketModal.tsx index 2fb54285..4e5431c2 100644 --- a/client/src/pages/makerspace_page/maintenance_pages/NewTicketModal.tsx +++ b/client/src/pages/makerspace_page/maintenance_pages/NewTicketModal.tsx @@ -13,6 +13,7 @@ import { toast } from "react-toastify"; import FileUploadButton from "../../../common/FileUploadButton"; import styled from "styled-components"; import { makeCDNLink } from "../../../common/ImageFinder"; +import { useIsMobile } from "../../../common/IsMobileProvider"; const StyledImg = styled.img` padding: 5px @@ -27,6 +28,7 @@ interface NewTicketModalProps { export default function NewTicketModal(props: NewTicketModalProps) { const user = useCurrentUser(); + const isMobile = useIsMobile(); const [equipment, setEquipment] = useState(props.equipment); const [instance, setInstance] = useState(); @@ -93,7 +95,7 @@ export default function NewTicketModal(props: NewTicketModalProps) { } return ( - + Create a Maintenance Ticket @@ -186,7 +188,7 @@ export default function NewTicketModal(props: NewTicketModalProps) { ? : null } - +