From f0b2292f0ae749516089a396ab79ac030b9fb3d0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 21:45:39 +0000 Subject: [PATCH 1/8] general --- .../src/Components/v2/inputs/DatePicker.tsx | 91 +++++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Hooks/useMaintenanceWindowForm.ts | 79 ++++++++++++ .../Components/ConfigSelect/index.jsx | 0 .../Components/MonitorList/index.jsx | 0 .../Components/MonitorsConfig/index.jsx | 0 .../hooks/useMaintenanceActions.jsx | 0 .../hooks/useMaintenanceData.jsx | 0 .../{CreateMaintenance => create}/index.css | 0 client/src/Pages/Maintenance/create/index.tsx | 120 ++++++++++++++++++ .../index.jsx => create/old.jsx} | 0 client/src/Routes/index.jsx | 10 +- client/src/Validation/maintenanceWindow.ts | 32 +++++ client/src/locales/en.json | 52 ++++++-- 14 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 client/src/Components/v2/inputs/DatePicker.tsx create mode 100644 client/src/Hooks/useMaintenanceWindowForm.ts rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/ConfigSelect/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/MonitorList/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/Components/MonitorsConfig/index.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/hooks/useMaintenanceActions.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/hooks/useMaintenanceData.jsx (100%) rename client/src/Pages/Maintenance/{CreateMaintenance => create}/index.css (100%) create mode 100644 client/src/Pages/Maintenance/create/index.tsx rename client/src/Pages/Maintenance/{CreateMaintenance/index.jsx => create/old.jsx} (100%) create mode 100644 client/src/Validation/maintenanceWindow.ts diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/v2/inputs/DatePicker.tsx new file mode 100644 index 000000000..4a852f2f1 --- /dev/null +++ b/client/src/Components/v2/inputs/DatePicker.tsx @@ -0,0 +1,91 @@ +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker"; +import type { Dayjs } from "dayjs"; +import { useTheme } from "@mui/material/styles"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "./FieldLabel"; +import { Calendar } from "lucide-react"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers"; + +interface DatePickerComponentProps extends Omit, "label"> { + fieldLabel?: string; + required?: boolean; +} + +export const DatePickerComponent = ({ + fieldLabel, + required, + ...props +}: DatePickerComponentProps) => { + const theme = useTheme(); + + const picker = ( + + ( + + ), + ...props.slots, + }} + slotProps={{ + switchViewButton: { sx: { display: "none" } }, + nextIconButton: { sx: { ml: theme.spacing(2) } }, + day: { + sx: { + "&.MuiPickersDay-root.Mui-disabled": { + color: theme.palette.text.disabled, + }, + }, + }, + field: { + sx: { + width: "fit-content", + "& > .MuiOutlinedInput-root": { + flexDirection: "row-reverse", + }, + "& input": { + minHeight: 34, + p: 0, + pl: theme.spacing(3), + pr: theme.spacing(5), + }, + "& fieldset": { + borderColor: theme.palette.divider, + borderRadius: theme.shape.borderRadius, + }, + "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": + { + borderColor: theme.palette.divider, + }, + }, + }, + inputAdornment: { sx: { ml: 0 } }, + openPickerButton: { + sx: { + py: 0, + mr: 0, + }, + }, + ...props.slotProps, + }} + /> + + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {picker} + + ); + } + + return picker; +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index fa04903c9..6cb40aade 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -14,3 +14,4 @@ export * from "./Switch"; export * from "./Slider"; export * from "./ImageUpload"; export * from "./ColorPicker"; +export { DatePickerComponent as DatePicker } from "./DatePicker"; diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts new file mode 100644 index 000000000..b1a6f86ad --- /dev/null +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -0,0 +1,79 @@ +import { useMemo } from "react"; +import dayjs from "dayjs"; +import { + maintenanceWindowSchema, + repeatOptions, + durationUnitOptions, + type MaintenanceWindowFormData, +} from "@/Validation/maintenanceWindow"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; + +interface UseMaintenanceWindowFormOptions { + data?: MaintenanceWindow | null; +} + +const getRepeatId = (repeatMs: number): string => { + const option = repeatOptions.find((opt) => opt.value === repeatMs); + return option?.id ?? "none"; +}; + +const getDurationAndUnit = ( + startMs: number, + endMs: number +): { duration: number; durationUnit: string } => { + const diffMs = endMs - startMs; + + for (const unit of [...durationUnitOptions].reverse()) { + if (diffMs >= unit.multiplier && diffMs % unit.multiplier === 0) { + return { + duration: diffMs / unit.multiplier, + durationUnit: unit.id, + }; + } + } + + return { + duration: diffMs / 1000, + durationUnit: "seconds", + }; +}; + +export const useMaintenanceWindowForm = ({ + data = null, +}: UseMaintenanceWindowFormOptions = {}) => { + return useMemo(() => { + let defaults: MaintenanceWindowFormData; + + if (data) { + const startDate = dayjs(data.start); + const endDate = dayjs(data.end); + const { duration, durationUnit } = getDurationAndUnit( + startDate.valueOf(), + endDate.valueOf() + ); + + defaults = { + name: data.name, + repeat: getRepeatId(data.repeat), + startDate: startDate.format("YYYY-MM-DD"), + startTime: startDate.format("HH:mm"), + duration, + durationUnit, + monitors: [data.monitorId], + }; + } else { + const now = dayjs(); + defaults = { + name: "", + repeat: "none", + startDate: now.format("YYYY-MM-DD"), + startTime: now.format("HH:mm"), + duration: 0, + durationUnit: "minutes", + monitors: [], + }; + } + + return { schema: maintenanceWindowSchema, defaults }; + }, [data]); +}; diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx b/client/src/Pages/Maintenance/create/Components/ConfigSelect/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/ConfigSelect/index.jsx rename to client/src/Pages/Maintenance/create/Components/ConfigSelect/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorList/index.jsx b/client/src/Pages/Maintenance/create/Components/MonitorList/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorList/index.jsx rename to client/src/Pages/Maintenance/create/Components/MonitorList/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx b/client/src/Pages/Maintenance/create/Components/MonitorsConfig/index.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx rename to client/src/Pages/Maintenance/create/Components/MonitorsConfig/index.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx b/client/src/Pages/Maintenance/create/hooks/useMaintenanceActions.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceActions.jsx rename to client/src/Pages/Maintenance/create/hooks/useMaintenanceActions.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx b/client/src/Pages/Maintenance/create/hooks/useMaintenanceData.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/hooks/useMaintenanceData.jsx rename to client/src/Pages/Maintenance/create/hooks/useMaintenanceData.jsx diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.css b/client/src/Pages/Maintenance/create/index.css similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/index.css rename to client/src/Pages/Maintenance/create/index.css diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx new file mode 100644 index 000000000..15948ae3b --- /dev/null +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -0,0 +1,120 @@ +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; +import { TextField, Select, DatePicker } from "@/Components/v2/inputs"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; +import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow"; +import { repeatOptions } from "@/Validation/maintenanceWindow"; +import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; +import { useGet } from "@/Hooks/UseApi"; +import { useParams } from "react-router-dom"; +import { useForm, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; + +const CreateMaintenanceWindowPage = () => { + const theme = useTheme(); + const { t } = useTranslation(); + const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>(); + const isEditMode = Boolean(maintenanceWindowId); + + const { data: existingMaintenanceWindow } = useGet( + isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null + ); + + const { schema, defaults } = useMaintenanceWindowForm({ + data: existingMaintenanceWindow, + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaults, + }); + + const { control } = form; + + return ( + + + ( + + )} + /> + ( + + )} + /> + + } + /> + + ( + { + field.onChange(date ? date.format("YYYY-MM-DD") : ""); + }} + /> + )} + /> + + } + /> + + ); +}; + +export default CreateMaintenanceWindowPage; diff --git a/client/src/Pages/Maintenance/CreateMaintenance/index.jsx b/client/src/Pages/Maintenance/create/old.jsx similarity index 100% rename from client/src/Pages/Maintenance/CreateMaintenance/index.jsx rename to client/src/Pages/Maintenance/create/old.jsx diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index ee08dc581..d4a5619d6 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -49,7 +49,7 @@ import EditUser from "../Pages/Account/EditUser/index.jsx"; import Settings from "../Pages/Settings"; import Maintenance from "../Pages/Maintenance"; -import CreateNewMaintenanceWindow from "../Pages/Maintenance/CreateMaintenance/index.jsx"; +import CreateNewMaintenanceWindow from "@/Pages/Maintenance/create"; import ProtectedRoute from "../Components/v1/ProtectedRoute"; import RoleProtectedRoute from "../Components/v1/RoleProtectedRoute"; @@ -313,7 +313,13 @@ const Routes = () => { /> } + element={ + <> + + + + + } /> ; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 2f61a5f62..3d2e0ba61 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -792,6 +792,45 @@ "nextWindow": "Next window", "repeat": "Repeat" } + }, + "form": { + "general": { + "title": "General settings", + "description": "Set a name and repeat option for your maintenance window.", + "option": { + "name": { + "label": "Name", + "placeholder": "e.g. Weekly Maintenance" + }, + "repeat": { + "label": "Repeat" + } + } + }, + "startTime": { + "title": "Start time", + "description": "Set the start time and duration for your maintenance window.", + "option": { + "duration": { + "label": "Duration" + }, + "startDate": { + "label": "Start date" + }, + "startTime": { + "label": "Start time" + } + }, + "monitors": { + "title": "Monitors", + "description": "Monitors that the maintenance window should apply to", + "option": { + "addMonitors": { + "label": "Add monitors" + } + } + } + } } }, "notifications": { @@ -1282,16 +1321,5 @@ "monitorDeleted": "Monitor deleted successfully", "monitorPaused": "Monitor paused successfully", "monitorResumed": "Monitor resumed successfully", - "name": "Name", - "maintenanceWindow": { - "fallback": { - "actionButton": "Let's create your first maintenance window!", - "checks": [ - "Mark your maintenance periods", - "Eliminate any misunderstandings", - "Stop sending alerts in maintenance windows" - ], - "title": "A maintenance window is used to:" - } - } + "name": "Name" } From 4f0017c03186115fed71f902bb556e7eb96db244 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 21:47:56 +0000 Subject: [PATCH 2/8] strings --- client/src/Pages/Maintenance/create/index.tsx | 6 +++--- client/src/locales/en.json | 13 ++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 15948ae3b..521832ddc 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -90,8 +90,8 @@ const CreateMaintenanceWindowPage = () => { } /> { render={({ field }) => ( { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 3d2e0ba61..a4661575b 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -807,6 +807,15 @@ } } }, + "startDate": { + "title": "Start date", + "description": "Select the start date for your maintenance window.", + "option": { + "startDate": { + "label": "Start date" + } + } + }, "startTime": { "title": "Start time", "description": "Set the start time and duration for your maintenance window.", @@ -814,9 +823,7 @@ "duration": { "label": "Duration" }, - "startDate": { - "label": "Start date" - }, + "startTime": { "label": "Start time" } From 0a5067ef46748ca9a00db82f47d9b4306462751c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 22:10:52 +0000 Subject: [PATCH 3/8] grab monitors --- .../src/Components/v2/inputs/TimePicker.tsx | 61 ++++++++++ client/src/Components/v2/inputs/index.tsx | 1 + client/src/Pages/Maintenance/create/index.tsx | 115 +++++++++++++++++- 3 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 client/src/Components/v2/inputs/TimePicker.tsx diff --git a/client/src/Components/v2/inputs/TimePicker.tsx b/client/src/Components/v2/inputs/TimePicker.tsx new file mode 100644 index 000000000..251332fe1 --- /dev/null +++ b/client/src/Components/v2/inputs/TimePicker.tsx @@ -0,0 +1,61 @@ +import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker"; +import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker"; +import type { Dayjs } from "dayjs"; +import { useTheme } from "@mui/material/styles"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "./FieldLabel"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers"; + +interface TimePickerComponentProps extends Omit, "label"> { + fieldLabel?: string; + required?: boolean; +} + +export const TimePickerComponent = ({ + fieldLabel, + required, + ...props +}: TimePickerComponentProps) => { + const theme = useTheme(); + + const picker = ( + + + + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {picker} + + ); + } + + return picker; +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 6cb40aade..25d9febe3 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -15,3 +15,4 @@ export * from "./Slider"; export * from "./ImageUpload"; export * from "./ColorPicker"; export { DatePickerComponent as DatePicker } from "./DatePicker"; +export { TimePickerComponent as TimePicker } from "./TimePicker"; diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 521832ddc..a15338436 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -2,17 +2,24 @@ import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; -import { TextField, Select, DatePicker } from "@/Components/v2/inputs"; +import { + TextField, + Select, + DatePicker, + TimePicker, + Button, +} from "@/Components/v2/inputs"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow"; -import { repeatOptions } from "@/Validation/maintenanceWindow"; +import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow"; import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; import { useGet } from "@/Hooks/UseApi"; import { useParams } from "react-router-dom"; +import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; @@ -26,6 +33,8 @@ const CreateMaintenanceWindowPage = () => { isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null ); + const { data: monitors } = useGet("/monitors/team"); + const { schema, defaults } = useMaintenanceWindowForm({ data: existingMaintenanceWindow, }); @@ -35,10 +44,21 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control } = form; + const { control, handleSubmit } = form; + + const onSubmit = (data: MaintenanceWindowFormData) => { + console.log("Form submitted with data:", data); + }; + + const onError = (errors: any) => { + console.log("Form errors:", errors); + }; return ( - + { } /> + + ( + { + field.onChange(time ? time.format("HH:mm") : ""); + }} + /> + )} + /> + + ( + { + const val = e.target.value; + field.onChange(val === "" ? 0 : Number(val)); + }} + error={!!fieldState.error} + helperText={fieldState.error?.message ?? ""} + sx={{ width: 120 }} + /> + )} + /> + ( + + )} + /> + + + } + /> + + + ); }; From dc0e198a8ed47861b3789f79c779317c21fd7b59 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 4 Feb 2026 22:17:04 +0000 Subject: [PATCH 4/8] monitors --- client/src/Pages/Maintenance/create/index.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index a15338436..7190adf32 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -1,6 +1,7 @@ import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; import { BasePage, ConfigBox } from "@/Components/v2/design-elements"; import { TextField, @@ -8,6 +9,7 @@ import { DatePicker, TimePicker, Button, + Autocomplete, } from "@/Components/v2/inputs"; import { useTheme } from "@mui/material"; @@ -22,6 +24,7 @@ import { useParams } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; +import { Trash2 } from "lucide-react"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); @@ -206,6 +209,85 @@ const CreateMaintenanceWindowPage = () => { } /> + { + const monitorsList = monitors ?? []; + const selectedMonitors = field.value + .map((id: string) => monitorsList.find((m) => m.id === id)) + .filter((m): m is Monitor => m !== undefined); + + return ( + + option.name} + value={selectedMonitors} + onChange={(_, newValue) => { + field.onChange(newValue.map((m: Monitor) => m.id)); + }} + fieldLabel={t( + "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" + )} + renderInput={(params) => ( + + )} + /> + {selectedMonitors.length > 0 && ( + + {selectedMonitors.map((monitor) => ( + + {monitor.name} + { + field.onChange( + field.value.filter( + (id: string) => id !== monitor.id + ) + ); + }} + aria-label="Remove monitor" + > + + + + ))} + + )} + + ); + }} + /> + } + /> Date: Wed, 4 Feb 2026 22:55:54 +0000 Subject: [PATCH 5/8] duration --- .../src/Components/v2/inputs/DatePicker.tsx | 33 +++++--- client/src/Hooks/useMaintenanceWindowForm.ts | 31 +------ client/src/Pages/Maintenance/create/index.tsx | 84 +++++++++++++++---- client/src/Types/MaintenanceWindow.ts | 4 + client/src/Validation/maintenanceWindow.ts | 41 +++++---- client/src/locales/en.json | 2 +- server/src/db/models/MaintenanceWindow.ts | 9 +- .../MongoMaintenanceWindowsRepository.ts | 2 + server/src/types/maintenanceWindow.ts | 4 + server/src/validation/joi.ts | 4 + 10 files changed, 142 insertions(+), 72 deletions(-) diff --git a/client/src/Components/v2/inputs/DatePicker.tsx b/client/src/Components/v2/inputs/DatePicker.tsx index 4a852f2f1..c63c5ce08 100644 --- a/client/src/Components/v2/inputs/DatePicker.tsx +++ b/client/src/Components/v2/inputs/DatePicker.tsx @@ -3,6 +3,7 @@ import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker"; import type { Dayjs } from "dayjs"; import { useTheme } from "@mui/material/styles"; import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { FieldLabel } from "./FieldLabel"; import { Calendar } from "lucide-react"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; @@ -11,11 +12,15 @@ import { LocalizationProvider } from "@mui/x-date-pickers"; interface DatePickerComponentProps extends Omit, "label"> { fieldLabel?: string; required?: boolean; + error?: boolean; + helperText?: string; } export const DatePickerComponent = ({ fieldLabel, required, + error, + helperText, ...props }: DatePickerComponentProps) => { const theme = useTheme(); @@ -56,12 +61,12 @@ export const DatePickerComponent = ({ pr: theme.spacing(5), }, "& fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, borderRadius: theme.shape.borderRadius, }, "&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset": { - borderColor: theme.palette.divider, + borderColor: error ? theme.palette.error.main : theme.palette.divider, }, }, }, @@ -78,14 +83,18 @@ export const DatePickerComponent = ({ ); - if (fieldLabel) { - return ( - - {fieldLabel} - {picker} - - ); - } - - return picker; + return ( + + {fieldLabel && {fieldLabel}} + {picker} + {helperText && ( + + {helperText} + + )} + + ); }; diff --git a/client/src/Hooks/useMaintenanceWindowForm.ts b/client/src/Hooks/useMaintenanceWindowForm.ts index b1a6f86ad..1f10d55ef 100644 --- a/client/src/Hooks/useMaintenanceWindowForm.ts +++ b/client/src/Hooks/useMaintenanceWindowForm.ts @@ -3,7 +3,6 @@ import dayjs from "dayjs"; import { maintenanceWindowSchema, repeatOptions, - durationUnitOptions, type MaintenanceWindowFormData, } from "@/Validation/maintenanceWindow"; import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; @@ -17,27 +16,6 @@ const getRepeatId = (repeatMs: number): string => { return option?.id ?? "none"; }; -const getDurationAndUnit = ( - startMs: number, - endMs: number -): { duration: number; durationUnit: string } => { - const diffMs = endMs - startMs; - - for (const unit of [...durationUnitOptions].reverse()) { - if (diffMs >= unit.multiplier && diffMs % unit.multiplier === 0) { - return { - duration: diffMs / unit.multiplier, - durationUnit: unit.id, - }; - } - } - - return { - duration: diffMs / 1000, - durationUnit: "seconds", - }; -}; - export const useMaintenanceWindowForm = ({ data = null, }: UseMaintenanceWindowFormOptions = {}) => { @@ -46,19 +24,14 @@ export const useMaintenanceWindowForm = ({ if (data) { const startDate = dayjs(data.start); - const endDate = dayjs(data.end); - const { duration, durationUnit } = getDurationAndUnit( - startDate.valueOf(), - endDate.valueOf() - ); defaults = { name: data.name, repeat: getRepeatId(data.repeat), startDate: startDate.format("YYYY-MM-DD"), startTime: startDate.format("HH:mm"), - duration, - durationUnit, + duration: data.duration ?? 0, + durationUnit: data.durationUnit ?? "minutes", monitors: [data.monitorId], }; } else { diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index 7190adf32..0c247b017 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -19,25 +19,32 @@ import type { MaintenanceWindow } from "@/Types/MaintenanceWindow"; import type { MaintenanceWindowFormData } from "@/Validation/maintenanceWindow"; import { repeatOptions, durationUnitOptions } from "@/Validation/maintenanceWindow"; import { useMaintenanceWindowForm } from "@/Hooks/useMaintenanceWindowForm"; -import { useGet } from "@/Hooks/UseApi"; -import { useParams } from "react-router-dom"; +import { useGet, usePost, usePatch } from "@/Hooks/UseApi"; +import { useParams, useNavigate } from "react-router-dom"; import type { Monitor } from "@/Types/Monitor"; import { useForm, Controller } from "react-hook-form"; +import { useEffect } from "react"; import { zodResolver } from "@hookform/resolvers/zod/dist/zod.js"; import { Trash2 } from "lucide-react"; const CreateMaintenanceWindowPage = () => { const theme = useTheme(); const { t } = useTranslation(); + const navigate = useNavigate(); const { maintenanceWindowId } = useParams<{ maintenanceWindowId: string }>(); const isEditMode = Boolean(maintenanceWindowId); const { data: existingMaintenanceWindow } = useGet( - isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null + isEditMode ? `/maintenance-window/${maintenanceWindowId}` : null, + {}, + { keepPreviousData: false } ); const { data: monitors } = useGet("/monitors/team"); + const { post, loading: isPosting } = usePost(); + const { patch, loading: isPatching } = usePatch(); + const { schema, defaults } = useMaintenanceWindowForm({ data: existingMaintenanceWindow, }); @@ -47,14 +54,61 @@ const CreateMaintenanceWindowPage = () => { defaultValues: defaults, }); - const { control, handleSubmit } = form; + const { control, handleSubmit, trigger } = form; + useEffect(() => { + if (existingMaintenanceWindow) { + form.reset(defaults); + } + }, [existingMaintenanceWindow, defaults, form]); - const onSubmit = (data: MaintenanceWindowFormData) => { - console.log("Form submitted with data:", data); + const buildPayload = (data: MaintenanceWindowFormData) => { + const startDateTime = dayjs(data.startDate) + .set("hour", parseInt(data.startTime.split(":")[0], 10)) + .set("minute", parseInt(data.startTime.split(":")[1], 10)); + + const durationUnit = durationUnitOptions.find((o) => o.id === data.durationUnit); + const durationInMs = data.duration * (durationUnit?.multiplier ?? 1000); + const endDateTime = startDateTime.add(durationInMs, "milliseconds"); + + const repeatOption = repeatOptions.find((o) => o.id === data.repeat); + const repeat = repeatOption?.value ?? 0; + + const payload: Record = { + name: data.name, + duration: data.duration, + durationUnit: data.durationUnit, + monitors: data.monitors, + start: startDateTime.toISOString(), + end: endDateTime.toISOString(), + repeat, + }; + + if (repeat === 0) { + payload.expiry = endDateTime.toISOString(); + } + + return payload; }; + const onSubmit = async (data: MaintenanceWindowFormData) => { + const payload = buildPayload(data); + + let result; + if (isEditMode && maintenanceWindowId) { + result = await patch(`/maintenance-window/${maintenanceWindowId}`, payload); + } else { + result = await post("/maintenance-window", payload); + } + + if (result?.success) { + navigate("/maintenance"); + } + }; + + const isLoading = isPosting || isPatching; + const onError = (errors: any) => { - console.log("Form errors:", errors); + console.error("Form submission errors:", errors); }; return ( @@ -121,7 +175,7 @@ const CreateMaintenanceWindowPage = () => { name="startDate" control={control} defaultValue={defaults.startDate} - render={({ field }) => ( + render={({ field, fieldState }) => ( { onChange={(date) => { field.onChange(date ? date.format("YYYY-MM-DD") : ""); }} + error={!!fieldState.error} + helperText={fieldState.error?.message} /> )} /> @@ -152,7 +208,7 @@ const CreateMaintenanceWindowPage = () => { )} value={field.value ? dayjs(field.value, "HH:mm") : null} onChange={(time) => { - field.onChange(time ? time.format("HH:mm") : ""); + field.onChange(time ? time.format("HH:mm") : ""); trigger("startDate"); }} /> )} @@ -241,7 +297,9 @@ const CreateMaintenanceWindowPage = () => { {...params} placeholder={ selectedMonitors.length === 0 - ? t("pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label") + ? t( + "pages.maintenanceWindow.form.startTime.monitors.option.addMonitors.label" + ) : "" } error={!!fieldState.error} @@ -269,9 +327,7 @@ const CreateMaintenanceWindowPage = () => { size="small" onClick={() => { field.onChange( - field.value.filter( - (id: string) => id !== monitor.id - ) + field.value.filter((id: string) => id !== monitor.id) ); }} aria-label="Remove monitor" @@ -294,7 +350,7 @@ const CreateMaintenanceWindowPage = () => { spacing={theme.spacing(2)} >