From 04f992dc16ce2056a113df6d93fce068efdb06e0 Mon Sep 17 00:00:00 2001 From: cowsed Date: Tue, 21 Jul 2026 14:51:26 -0400 Subject: [PATCH 1/2] Creating tickets of types, updating them at midnight, needs testing --- .../equipment_pages/EquipmentInstanceCard.tsx | 17 +---- .../equipment_pages/EquipmentInstanceRow.tsx | 18 +---- .../MaintenanceTicketModal.tsx | 6 ++ .../NewIntervalTicketModal.tsx | 69 ++++++++++++++----- .../src/queries/equipmentInstanceQueries.ts | 15 ++++ .../src/queries/maintenanceTicketQueries.ts | 27 +++++++- .../20260721160735_hobbs_time_tickets.ts | 27 ++++++++ server/src/database/knex/tables.ts | 10 ++- .../Equipment/MaintenanceTicketRepository.ts | 53 ++++++++++---- .../resolvers/maintenanceTicketResolver.ts | 12 ++-- .../schemas/maintenanceTicketSchema.ts | 12 +++- server/src/periodicActions.ts | 7 +- server/src/server.ts | 2 +- 13 files changed, 200 insertions(+), 75 deletions(-) create mode 100644 server/src/database/knex/migrations/20260721160735_hobbs_time_tickets.ts diff --git a/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceCard.tsx b/client/src/pages/makerspace_page/equipment_pages/EquipmentInstanceCard.tsx index 445484dd0..716447295 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 83c298ddb..9256b1278 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/maintenance_pages/MaintenanceTicketModal.tsx b/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx index 838714755..eab8436c4 100644 --- a/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx +++ b/client/src/pages/makerspace_page/maintenance_pages/MaintenanceTicketModal.tsx @@ -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,7 @@ export default function MaintenanceTicketModal(props: TicketModalProps) { } } + return ( @@ -317,6 +319,10 @@ export default function MaintenanceTicketModal(props: TicketModalProps) { } + + Usage at Open: {formatHobbsTime(props.ticket.hobbsTimeAtCreate)} + Usage at Close: {props.ticket.hobbsTimeAtClose ? formatHobbsTime(props.ticket.hobbsTimeAtClose) : "N/A"} + { editing ? , 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,6 +73,7 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { setImageUrl(undefined); setStartDate(new Date()); setScale("days"); + setTimeUnit("calendar") setInterval("1"); props.onClose(); @@ -78,6 +85,7 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { return; } try { + const intervalHours = scale === "days" ? Number(interval) * 24 : (scale == "weeks" ? Number(interval) * 168 : interval) await createTicket({ variables: { severity: severity, @@ -85,7 +93,10 @@ 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, + hobbsTimeAtCreate: instance?.hobbsTime + } }) } catch (e) { @@ -127,6 +138,7 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { { equipment ? ( @@ -159,21 +171,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/queries/equipmentInstanceQueries.ts b/client/src/queries/equipmentInstanceQueries.ts index c382aa6dd..1e81b98ad 100644 --- a/client/src/queries/equipmentInstanceQueries.ts +++ b/client/src/queries/equipmentInstanceQueries.ts @@ -22,6 +22,21 @@ export interface EquipmentInstance { accessController: AccessController | undefined; hobbsTime: number } +export function formatHobbsTime(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" + } +} + + export const GET_EQUIPMENT_INSTANCES = gql` query EquipmentInstances($equipmentID: ID!) { diff --git a/client/src/queries/maintenanceTicketQueries.ts b/client/src/queries/maintenanceTicketQueries.ts index 4f4cf8755..bf29c0434 100644 --- a/client/src/queries/maintenanceTicketQueries.ts +++ b/client/src/queries/maintenanceTicketQueries.ts @@ -2,6 +2,11 @@ import { gql } from "@apollo/client"; import { CurrentUser } from "../common/CurrentUserProvider"; import { EquipmentInstance } from "./equipmentInstanceQueries"; +export enum MaintenanceTicketTimeUnit { + USAGE = "USAGE", + CALENDAR = "CALENDAR" +} + export enum MaintenanceTicketType { AUTOMATIC = "AUTOMATIC", REPORTED = "REPORTED" @@ -32,7 +37,10 @@ export interface MaintenanceTicket { creator: CurrentUser | null, instance: EquipmentInstance, assigned: CurrentUser | null, - intervalHours: number | null + intervalHours: number | null, + timeUnit: MaintenanceTicketTimeUnit, + hobbsTimeAtCreate: number, + hobbsTimeAtClose: number | null } export const PAGINATED_MAINTENANCE_TICKETS = gql` @@ -47,6 +55,9 @@ export const PAGINATED_MAINTENANCE_TICKETS = gql` dateCreated dateClosed intervalHours + timeUnit + hobbsTimeAtCreate + hobbsTimeAtClose creator { id ritUsername @@ -80,6 +91,9 @@ export const GET_MAINTENANCE_TICKET = gql` imageUrl dateCreated dateClosed + timeUnit + hobbsTimeAtCreate + hobbsTimeAtClose creator { id ritUsername @@ -133,6 +147,9 @@ export const GET_MAINTENANCE_TICKETS = gql` dateCreated dateClosed intervalHours + timeUnit + hobbsTimeAtCreate + hobbsTimeAtClose creator { id ritUsername @@ -180,7 +197,9 @@ export const CREATE_INTERVAL_MAINTENANCE_TICKET = gql` $description: String!, $startDate: String!, $intervalHours: Int!, - $imageUrl: String + $imageUrl: String, + $timeUnit: String!, + $hobbsTimeAtCreate: Int!, ) { createIntervalMaintenanceTicket( severity: $severity, @@ -188,7 +207,9 @@ export const CREATE_INTERVAL_MAINTENANCE_TICKET = gql` description: $description, startDate: $startDate, intervalHours: $intervalHours, - imageUrl: $imageUrl + imageUrl: $imageUrl, + timeUnit: $timeUnit, + hobbsTimeAtCreate: $hobbsTimeAtCreate, ) { id } diff --git a/server/src/database/knex/migrations/20260721160735_hobbs_time_tickets.ts b/server/src/database/knex/migrations/20260721160735_hobbs_time_tickets.ts new file mode 100644 index 000000000..4dfc5008d --- /dev/null +++ b/server/src/database/knex/migrations/20260721160735_hobbs_time_tickets.ts @@ -0,0 +1,27 @@ +import type { Knex } from "knex"; + + +export async function up(knex: Knex): Promise { + knex.schema.hasTable("MaintenanceTickets").then(function (exists) { + if (!exists) return; + + return knex.schema.alterTable("MaintenanceTickets", function (t) { + t.enum("timeUnit", ["USAGE", "CALENDAR"]).notNullable().defaultTo("CALENDAR") + t.integer("hobbsTimeAtCreate").notNullable().defaultTo(0); + t.integer("hobbsTimeAtClose").nullable(); + }); + }); +} + + +export async function down(knex: Knex): Promise { + knex.schema.hasTable("MaintenanceTickets").then(function (exists) { + if (!exists) return; + + return knex.schema.alterTable("MaintenanceTickets", function (t) { + t.dropColumns("timeUnit", "hobbsTimeAtCreate", "hobbsTimeAtClose"); + }); + }); + +} + diff --git a/server/src/database/knex/tables.ts b/server/src/database/knex/tables.ts index b83ccb446..5029e0b38 100644 --- a/server/src/database/knex/tables.ts +++ b/server/src/database/knex/tables.ts @@ -626,6 +626,11 @@ export interface ReservationRow { end: string; // ^ } +export enum MaintenanceTicketTimeUnit { + USAGE = "USAGE", + CALENDAR = "CALENDAR" +} + export enum MaintenanceTicketType { AUTOMATIC = "AUTOMATIC", REPORTED = "REPORTED" @@ -640,7 +645,7 @@ export enum MaintenanceTicketSeverity { export enum MaintenanceTicketStatus { UPCOMING = "UPCOMING", TODO = "TODO", - IN_PROGESS = "IN_PROGRESS", + IN_PROGRESS = "IN_PROGRESS", CLOSED = "CLOSED" } @@ -657,6 +662,9 @@ export interface MaintenanceTicketRow { dateClosed: string | null; // ^ intervalHours: number | null; assignedID: number | null; + timeUnit: MaintenanceTicketTimeUnit; + hobbsTimeAtCreate: number; + hobbsTimeAtClose: number | null; } export interface TempCardRow { diff --git a/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts b/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts index fe4232597..7bf878846 100644 --- a/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts +++ b/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts @@ -1,7 +1,8 @@ import { GraphQLError } from "graphql"; import { knex } from "../../knex/index.js"; -import { MaintenanceTicketRow, MaintenanceTicketSeverity, MaintenanceTicketStatus, MaintenanceTicketType } from "../../knex/tables.js"; +import { MaintenanceTicketRow, MaintenanceTicketSeverity, MaintenanceTicketStatus, MaintenanceTicketTimeUnit, MaintenanceTicketType } from "../../knex/tables.js"; import { addHours, endOfDay } from "date-fns" +import { getInstanceByID } from "./EquipmentInstancesRepository.js"; export async function createMaintenanceTicket( type: MaintenanceTicketType, @@ -31,6 +32,8 @@ export async function createIntervalMaintenanceTicket( instanceID: number, description: string, startDate: string, + hobbsTimeAtCreate: number, + timeUnit: MaintenanceTicketTimeUnit, intervalHours: number, imageUrl?: string ): Promise { @@ -42,7 +45,9 @@ export async function createIntervalMaintenanceTicket( description, dateCreated: startDate, intervalHours, - imageUrl + timeUnit: timeUnit, + imageUrl, + hobbsTimeAtCreate: hobbsTimeAtCreate }).returning("*"); if (result.length > 0) { @@ -63,19 +68,24 @@ export async function modifyMaintenanceTicketStatus(id: number, status: Maintena return 0; // don't update the status to the same thing } - if (status === MaintenanceTicketStatus.CLOSED) { - - if (ticket.type === MaintenanceTicketType.AUTOMATIC) { - - const newTargetDate = addHours(new Date(), ticket.intervalHours ?? 0); - await createIntervalMaintenanceTicket( - ticket.severity, - ticket.instanceID, - ticket.description, - newTargetDate.toISOString(), ticket.intervalHours ?? 0 - ) + if (status === MaintenanceTicketStatus.CLOSED && ticket.type === MaintenanceTicketType.AUTOMATIC) { + const inst = await getInstanceByID(ticket.instanceID) + if (!inst) { + throw new GraphQLError("Could not find instance for ticket to update hours") } + const newTargetDate = addHours(new Date(), ticket.intervalHours ?? 0); + const newTargetHobbs = inst.hobbsTime + (ticket.intervalHours ?? 0) * 60 * 60; + await createIntervalMaintenanceTicket( + ticket.severity, + ticket.instanceID, + ticket.description, + newTargetDate.toISOString(), + newTargetHobbs, + ticket.timeUnit, + ticket.intervalHours ?? 0 + ) + return await knex("MaintenanceTickets").update({ status: status, dateClosed: knex.fn.now() }).where({ id: id }); } else { return await knex("MaintenanceTickets").update({ status: status }).where({ id: id }); @@ -189,11 +199,24 @@ export async function assignMaintenanceTicket(id: number, assignedID: number | n return result[0]; } -export async function advanceIntervalTickets(): Promise { +export async function advanceCalendarIntervalTickets(): Promise { return await knex("MaintenanceTickets").update({ status: MaintenanceTicketStatus.TODO }) - .where("status", "=", MaintenanceTicketStatus.UPCOMING).andWhere("dateCreated", "<=", endOfDay(new Date()).toISOString()) // TODO: normalize this to timezone + .where("status", "=", MaintenanceTicketStatus.UPCOMING) + .andWhere("timeUnit", "=", MaintenanceTicketTimeUnit.CALENDAR) + .andWhere("dateCreated", "<=", endOfDay(new Date()).toISOString()) // TODO: normalize this to timezone .returning("*"); } +export async function advanceUsageIntervalTickets(): Promise { + return await knex("MaintenanceTickets as m") + .leftJoin("EquipmentInstances as e", "e.id", "=", "m.instanceID") + .update({ status: MaintenanceTicketStatus.TODO, dateCreated: knex.fn.now() }) + .where("status", "=", MaintenanceTicketStatus.UPCOMING) + .andWhere("timeUnit", "=", MaintenanceTicketTimeUnit.USAGE) + .andWhere("m.hobbsTimeAtCreate", "<=", "e.hobbsTime") + .returning("*"); +} + + export async function deleteMaintenanceTicket(id: number): Promise { return await knex("MaintenanceTickets").delete().where("id", "=", id); diff --git a/server/src/graphql/resolvers/maintenanceTicketResolver.ts b/server/src/graphql/resolvers/maintenanceTicketResolver.ts index 04ae8262f..629efc56e 100644 --- a/server/src/graphql/resolvers/maintenanceTicketResolver.ts +++ b/server/src/graphql/resolvers/maintenanceTicketResolver.ts @@ -1,5 +1,5 @@ import { ApolloContext } from "../../context.js" -import { MaintenanceTicketRow, MaintenanceTicketSeverity, MaintenanceTicketStatus, MaintenanceTicketType } from "../../database/knex/tables.js" +import { MaintenanceTicketRow, MaintenanceTicketSeverity, MaintenanceTicketStatus, MaintenanceTicketTimeUnit, MaintenanceTicketType } from "../../database/knex/tables.js" import * as MaintenanceTicketRepo from "../../database/repositories/Equipment/MaintenanceTicketRepository.js" import * as InstanceRepo from "../../database/repositories/Equipment/EquipmentInstancesRepository.js" import * as UserRepo from "../../database/repositories/Users/UserRepository.js" @@ -112,7 +112,9 @@ const MaintenanceTicketResolver = { description: string, startDate: string, intervalHours: number, - imageUrl?: string + imageUrl?: string, + timeUnit: MaintenanceTicketTimeUnit, + hobbsTimeAtCreate: number, }, { isManager }: ApolloContext // should perhaps be ifManagerFor ) => isManager(async (_user) => ( @@ -120,8 +122,10 @@ const MaintenanceTicketResolver = { args.severity, args.instanceID, args.description, - args.startDate, - args.intervalHours, + args.startDate, + args.hobbsTimeAtCreate, + args.timeUnit, + args.intervalHours, args.imageUrl ) )), diff --git a/server/src/graphql/schemas/maintenanceTicketSchema.ts b/server/src/graphql/schemas/maintenanceTicketSchema.ts index 16f54dc7a..a36ed2614 100644 --- a/server/src/graphql/schemas/maintenanceTicketSchema.ts +++ b/server/src/graphql/schemas/maintenanceTicketSchema.ts @@ -2,6 +2,11 @@ import { gql } from "graphql-tag"; export const MaintenanceTicketTypeDefs = gql` + enum MaintenanceTicketTimeUnit { + USAGE + CALENDAR + } + enum MaintenanceTicketType { AUTOMATIC REPORTED @@ -36,6 +41,9 @@ export const MaintenanceTicketTypeDefs = gql` instance: EquipmentInstance! creator: User assigned: User + timeUnit: MaintenanceTicketTimeUnit + hobbsTimeAtCreate: Int! + hobbsTimeAtClose: Int } input Pagination { @@ -75,7 +83,9 @@ export const MaintenanceTicketTypeDefs = gql` description: String!, startDate: String!, intervalHours: Int!, - imageUrl: String + imageUrl: String, + timeUnit: String!, + hobbsTimeAtCreate: Int! ): MaintenanceTicket modifyMaintenanceTicketStatus(id: Int!, status: MaintenanceTicketStatus!): Int diff --git a/server/src/periodicActions.ts b/server/src/periodicActions.ts index 9ea901f12..a7ebd92d6 100644 --- a/server/src/periodicActions.ts +++ b/server/src/periodicActions.ts @@ -5,7 +5,7 @@ import { notifyNewMaintenanceTicket } from "./integrations/slack/slack.js"; import { CoreActions } from "./database/models/api/ACSFormats.js"; import { ACSOrchestrator } from "./database/models/api/ACSOrchestrator.js"; import { createUnassocaitedAuditLog } from "./database/repositories/AuditLogs/AuditLogRepository.js"; -import { advanceIntervalTickets } from "./database/repositories/Equipment/MaintenanceTicketRepository.js"; +import { advanceCalendarIntervalTickets, advanceUsageIntervalTickets } from "./database/repositories/Equipment/MaintenanceTicketRepository.js"; /** * If you use the CDN to store user-uploads, make sure you update this query so it knows that those images are in use @@ -67,7 +67,10 @@ export async function deletePastSpecialHours(): Promise { export async function advanceTimeTickets(): Promise { try { - const result = await advanceIntervalTickets(); + const calendarResult = await advanceCalendarIntervalTickets(); + const usageResult = await advanceUsageIntervalTickets(); + + const result = [...calendarResult, ...usageResult] if (result.length > 0) { createUnassocaitedAuditLog(`Advanced ${result.length} time-based maintenance tickets`, "server"); } diff --git a/server/src/server.ts b/server/src/server.ts index dd776a21a..d2e3b9dfc 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -623,7 +623,7 @@ async function startServer() { // Delete past special hours await deletePastSpecialHours(); - // Advance any time-based maintennace tickets from UPCOMING -> TODO + // Advance any time-based maintenance tickets from UPCOMING -> TODO await advanceTimeTickets(); // Get a new root certificate for readers From c2e96fa5d7a3682cf3cd3fa3e369b3320bd82484 Mon Sep 17 00:00:00 2001 From: cowsed Date: Wed, 29 Jul 2026 12:00:22 -0400 Subject: [PATCH 2/2] Calculate hobbs create time server side, update modals to show correct time information without being too misleading, advance tickets and set parameters accordingly --- .../maintenance_pages/MaintenancePage.tsx | 12 +++--- .../MaintenanceTicketModal.tsx | 23 +++++++++-- .../NewIntervalTicketModal.tsx | 17 ++++---- .../src/queries/maintenanceTicketQueries.ts | 5 ++- .../Equipment/MaintenanceTicketRepository.ts | 40 +++++++++++-------- .../resolvers/maintenanceTicketResolver.ts | 16 ++++++-- .../schemas/maintenanceTicketSchema.ts | 1 - server/src/periodicActions.ts | 2 +- 8 files changed, 73 insertions(+), 43 deletions(-) diff --git a/client/src/pages/makerspace_page/maintenance_pages/MaintenancePage.tsx b/client/src/pages/makerspace_page/maintenance_pages/MaintenancePage.tsx index 45cc5d1a3..4f5113717 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 eab8436c4..b9312ff44 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'; @@ -195,6 +195,18 @@ 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 ( @@ -216,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"}`} @@ -320,8 +335,8 @@ export default function MaintenanceTicketModal(props: TicketModalProps) { } - Usage at Open: {formatHobbsTime(props.ticket.hobbsTimeAtCreate)} - Usage at Close: {props.ticket.hobbsTimeAtClose ? formatHobbsTime(props.ticket.hobbsTimeAtClose) : "N/A"} + {shouldShowHobbsAtOpen ? Usage at Open: {formatHobbsTime(props.ticket.hobbsTimeAtCreate)} : undefined} + {shouldShowHobbsAtClose ? Usage at Close: {props.ticket.hobbsTimeAtClose ? formatHobbsTime(props.ticket.hobbsTimeAtClose) : "N/A"} : undefined} { editing diff --git a/client/src/pages/makerspace_page/maintenance_pages/NewIntervalTicketModal.tsx b/client/src/pages/makerspace_page/maintenance_pages/NewIntervalTicketModal.tsx index 61ef476a5..79f5eacc1 100644 --- a/client/src/pages/makerspace_page/maintenance_pages/NewIntervalTicketModal.tsx +++ b/client/src/pages/makerspace_page/maintenance_pages/NewIntervalTicketModal.tsx @@ -7,7 +7,7 @@ import { FullMakerspace } from "../../../queries/makerspaceQueries"; import { useMemo, useState } from "react"; import { EquipmentInstance, GET_EQUIPMENT_INSTANCES } from "../../../queries/equipmentInstanceQueries"; import { useMutation, useQuery } from "@apollo/client/react"; -import { CREATE_INTERVAL_MAINTENANCE_TICKET, MaintenanceTicketSeverity } from "../../../queries/maintenanceTicketQueries"; +import { CREATE_INTERVAL_MAINTENANCE_TICKET, GET_MAINTENANCE_TICKETS, MaintenanceTicketSeverity, PAGINATED_MAINTENANCE_TICKETS } from "../../../queries/maintenanceTicketQueries"; import { toast } from "react-toastify"; import FileUploadButton from "../../../common/FileUploadButton"; import styled from "styled-components"; @@ -32,7 +32,7 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { const [imageUrl, setImageUrl] = useState(); 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,7 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { const makerspace_equipments = makerspace_equipments_2?.flat(1); const [startDate, setStartDate] = useState(new Date()); - const [timeUnit, setTimeUnit] = useState("USAGE"); // USAGE | CALENDAR + const [timeUnit, setTimeUnit] = useState(""); // USAGE | CALENDAR const [scale, setScale] = useState("days"); // hours | days | weeks const [interval, setInterval] = useState("1"); @@ -73,19 +73,20 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { setImageUrl(undefined); setStartDate(new Date()); setScale("days"); - setTimeUnit("calendar") + 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" ? Number(interval) * 24 : (scale == "weeks" ? Number(interval) * 168 : interval) + 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, @@ -95,8 +96,6 @@ export default function NewIntervalTicketModal(props: NewTicketModalProps) { imageUrl: imageUrl, intervalHours: intervalHours, timeUnit: timeUnit, - hobbsTimeAtCreate: instance?.hobbsTime - } }) } catch (e) { diff --git a/client/src/queries/maintenanceTicketQueries.ts b/client/src/queries/maintenanceTicketQueries.ts index bf29c0434..3088c8714 100644 --- a/client/src/queries/maintenanceTicketQueries.ts +++ b/client/src/queries/maintenanceTicketQueries.ts @@ -69,6 +69,7 @@ export const PAGINATED_MAINTENANCE_TICKETS = gql` id name } + hobbsTime } assigned { id @@ -105,6 +106,7 @@ export const GET_MAINTENANCE_TICKET = gql` id name } + hobbsTime } } } @@ -167,6 +169,7 @@ export const GET_MAINTENANCE_TICKETS = gql` id name } + hobbsTime } } } @@ -199,7 +202,6 @@ export const CREATE_INTERVAL_MAINTENANCE_TICKET = gql` $intervalHours: Int!, $imageUrl: String, $timeUnit: String!, - $hobbsTimeAtCreate: Int!, ) { createIntervalMaintenanceTicket( severity: $severity, @@ -209,7 +211,6 @@ export const CREATE_INTERVAL_MAINTENANCE_TICKET = gql` intervalHours: $intervalHours, imageUrl: $imageUrl, timeUnit: $timeUnit, - hobbsTimeAtCreate: $hobbsTimeAtCreate, ) { id } diff --git a/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts b/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts index 7bf878846..6dd8f755f 100644 --- a/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts +++ b/server/src/database/repositories/Equipment/MaintenanceTicketRepository.ts @@ -68,25 +68,28 @@ export async function modifyMaintenanceTicketStatus(id: number, status: Maintena return 0; // don't update the status to the same thing } + const inst = await getInstanceByID(ticket.instanceID) + if (!inst) { + throw new GraphQLError("Could not find instance for ticket to update hours") + } + if (status === MaintenanceTicketStatus.CLOSED && ticket.type === MaintenanceTicketType.AUTOMATIC) { - const inst = await getInstanceByID(ticket.instanceID) - if (!inst) { - throw new GraphQLError("Could not find instance for ticket to update hours") - } const newTargetDate = addHours(new Date(), ticket.intervalHours ?? 0); const newTargetHobbs = inst.hobbsTime + (ticket.intervalHours ?? 0) * 60 * 60; await createIntervalMaintenanceTicket( ticket.severity, ticket.instanceID, - ticket.description, + ticket.description, newTargetDate.toISOString(), newTargetHobbs, ticket.timeUnit, ticket.intervalHours ?? 0 ) - return await knex("MaintenanceTickets").update({ status: status, dateClosed: knex.fn.now() }).where({ id: id }); + return await knex("MaintenanceTickets").update({ status: status, hobbsTimeAtClose: inst.hobbsTime, dateClosed: knex.fn.now() }).where({ id: id }); + } else if (status === MaintenanceTicketStatus.CLOSED) { + return await knex("MaintenanceTickets").update({ status: status, hobbsTimeAtClose: inst.hobbsTime }).where({ id: id }); } else { return await knex("MaintenanceTickets").update({ status: status }).where({ id: id }); } @@ -200,20 +203,23 @@ export async function assignMaintenanceTicket(id: number, assignedID: number | n } export async function advanceCalendarIntervalTickets(): Promise { - return await knex("MaintenanceTickets").update({ status: MaintenanceTicketStatus.TODO }) - .where("status", "=", MaintenanceTicketStatus.UPCOMING) - .andWhere("timeUnit", "=", MaintenanceTicketTimeUnit.CALENDAR) - .andWhere("dateCreated", "<=", endOfDay(new Date()).toISOString()) // TODO: normalize this to timezone - .returning("*"); + + return await knex("MaintenanceTickets as mt") + .update({ status: MaintenanceTicketStatus.TODO, hobbsTimeAtCreate: knex.raw('e."hobbsTime"') }) + .updateFrom("EquipmentInstances as e").where("e.id", "=", knex.raw('mt."instanceID"')) + .andWhere("mt.status", "=", MaintenanceTicketStatus.UPCOMING) + .andWhere("mt.timeUnit", "=", MaintenanceTicketTimeUnit.CALENDAR) + .andWhere("mt.dateCreated", "<=", endOfDay(new Date()).toISOString()) // TODO: normalize this to timezone + .returning("mt.*"); } export async function advanceUsageIntervalTickets(): Promise { return await knex("MaintenanceTickets as m") - .leftJoin("EquipmentInstances as e", "e.id", "=", "m.instanceID") - .update({ status: MaintenanceTicketStatus.TODO, dateCreated: knex.fn.now() }) - .where("status", "=", MaintenanceTicketStatus.UPCOMING) - .andWhere("timeUnit", "=", MaintenanceTicketTimeUnit.USAGE) - .andWhere("m.hobbsTimeAtCreate", "<=", "e.hobbsTime") - .returning("*"); + .update({ status: MaintenanceTicketStatus.TODO, dateCreated: knex.fn.now(), hobbsTimeAtCreate: knex.raw('e."hobbsTime"') }) + .updateFrom("EquipmentInstances as e").where("e.id", "=", knex.raw('m."instanceID"')) + .andWhere("m.status", "=", MaintenanceTicketStatus.UPCOMING) + .andWhere("m.timeUnit", "=", MaintenanceTicketTimeUnit.USAGE) + .andWhere("m.hobbsTimeAtCreate", "<=", knex.raw('e."hobbsTime"')) + .returning("m.*"); } diff --git a/server/src/graphql/resolvers/maintenanceTicketResolver.ts b/server/src/graphql/resolvers/maintenanceTicketResolver.ts index 629efc56e..33d8556f7 100644 --- a/server/src/graphql/resolvers/maintenanceTicketResolver.ts +++ b/server/src/graphql/resolvers/maintenanceTicketResolver.ts @@ -7,6 +7,7 @@ import * as AuditLogRepo from "../../database/repositories/AuditLogs/AuditLogRep import * as EquipmentRepo from "../../database/repositories/Equipment/EquipmentRepository.js"; import { notifyNewMaintenanceTicket } from "../../integrations/slack/slack.js" import { Equipment } from "../../database/models/equipment/Equipment.js" +import { GraphQLError } from "graphql" const MaintenanceTicketResolver = { MaintenanceTicket: { @@ -114,21 +115,28 @@ const MaintenanceTicketResolver = { intervalHours: number, imageUrl?: string, timeUnit: MaintenanceTicketTimeUnit, - hobbsTimeAtCreate: number, }, { isManager }: ApolloContext // should perhaps be ifManagerFor - ) => isManager(async (_user) => ( + ) => isManager(async (_user) => { + const inst = await InstanceRepo.getInstanceByID(args.instanceID); + if (inst == undefined){ + throw new GraphQLError("Could not find instance for maintenance ticket"); + } + let hobbsTimeAtCreate = inst.hobbsTime; + if (args.timeUnit == MaintenanceTicketTimeUnit.USAGE){ + hobbsTimeAtCreate += args.intervalHours * 60 * 60; + } await MaintenanceTicketRepo.createIntervalMaintenanceTicket( args.severity, args.instanceID, args.description, args.startDate, - args.hobbsTimeAtCreate, + hobbsTimeAtCreate, args.timeUnit, args.intervalHours, args.imageUrl ) - )), + }), modifyMaintenanceTicketStatus: async ( _parent: any, diff --git a/server/src/graphql/schemas/maintenanceTicketSchema.ts b/server/src/graphql/schemas/maintenanceTicketSchema.ts index a36ed2614..e5a50b778 100644 --- a/server/src/graphql/schemas/maintenanceTicketSchema.ts +++ b/server/src/graphql/schemas/maintenanceTicketSchema.ts @@ -85,7 +85,6 @@ export const MaintenanceTicketTypeDefs = gql` intervalHours: Int!, imageUrl: String, timeUnit: String!, - hobbsTimeAtCreate: Int! ): MaintenanceTicket modifyMaintenanceTicketStatus(id: Int!, status: MaintenanceTicketStatus!): Int diff --git a/server/src/periodicActions.ts b/server/src/periodicActions.ts index a7ebd92d6..ba9a260c0 100644 --- a/server/src/periodicActions.ts +++ b/server/src/periodicActions.ts @@ -72,7 +72,7 @@ export async function advanceTimeTickets(): Promise { const result = [...calendarResult, ...usageResult] if (result.length > 0) { - createUnassocaitedAuditLog(`Advanced ${result.length} time-based maintenance tickets`, "server"); + createUnassocaitedAuditLog(`Advanced ${calendarResult.length} calendar-time and ${usageResult.length} usage-time maintenance tickets`, "server"); } result.forEach((ticket) => {