diff --git a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx index e2ae8852..00be6781 100644 --- a/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx +++ b/Data/Engine/web-interface/src/Devices/Filters/Filter_Editor.jsx @@ -13,6 +13,8 @@ import { Chip, Tooltip, Autocomplete, + Tabs, + Tab, } from "@mui/material"; import { FilterAlt as HeaderIcon, @@ -123,6 +125,22 @@ const OS_ICON_MAP = { const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))"; +const TABS = [ + { value: "name", label: "Name" }, + { value: "scope", label: "Scope" }, + { value: "criteria", label: "Criteria" }, + { value: "results", label: "Results" }, +]; + +const TabPanel = ({ value, active, children }) => { + if (value !== active) return null; + return ( + + {children} + + ); +}; + const AUTO_SIZE_COLUMNS = ["status", "site", "hostname", "description", "type", "os"]; const resolveApplyAll = (filter) => Boolean(filter?.applyToAllSites ?? filter?.apply_to_all_sites); @@ -199,7 +217,7 @@ const normalizeGroupsForUI = (rawGroups) => { }); }; -export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) { +export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved, onPageMetaChange }) { const [name, setName] = useState(initialFilter?.name || ""); const initialScope = resolveSiteScope(initialFilter); const [scope, setScope] = useState(initialScope === "scoped" ? "site" : "global"); @@ -218,6 +236,8 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) const [previewLoading, setPreviewLoading] = useState(false); const [previewError, setPreviewError] = useState(null); const [previewAppliedAt, setPreviewAppliedAt] = useState(null); + const [tab, setTab] = useState(TABS[0].value); + const isEditing = Boolean(initialFilter); const gridRef = useRef(null); const sendNotification = useCallback(async (message) => { if (!message) return; @@ -251,6 +271,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) ); const gridFontFamily = "'IBM Plex Sans','Helvetica Neue',Arial,sans-serif"; const iconFontFamily = "'Quartz Regular'"; + const pageTitle = isEditing ? "Edit Device Filter" : "Create Device Filter"; + const pageSubtitle = + "Combine grouped criteria with AND/OR logic to build reusable device scopes for automation and reporting."; const applyFilterData = useCallback((filter) => { if (!filter) return; @@ -268,6 +291,15 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) applyFilterData(initialFilter); }, [applyFilterData, initialFilter]); + useEffect(() => { + onPageMetaChange?.({ + page_title: pageTitle, + page_subtitle: pageSubtitle, + page_icon: HeaderIcon, + }); + return () => onPageMetaChange?.(null); + }, [onPageMetaChange, pageSubtitle, pageTitle]); + const handleGridReady = useCallback((params) => { gridRef.current = params.api; requestAnimationFrame(() => { @@ -659,7 +691,7 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) key={condition.id} sx={{ display: "grid", - gridTemplateColumns: "110px 220px 220px 1fr auto", + gridTemplateColumns: "94px 220px 220px 1fr auto", gap: 0.5, alignItems: "center", background: "rgba(12,18,35,0.7)", @@ -777,6 +809,9 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) elevation={0} sx={{ minHeight: "100vh", + height: "100vh", + flex: 1, + position: "relative", backgroundColor: "transparent", color: AURORA_SHELL.text, p: 3, @@ -784,41 +819,39 @@ export default function DeviceFilterEditor({ initialFilter, onCancel, onSaved }) display: "flex", flexDirection: "column", gap: 3, - pb: 6, + pb: 3, + overflow: "hidden", }} > - - - - - - - - {initialFilter ? "Edit Device Filter" : "Create Device Filter"} - - - Combine grouped criteria with AND/OR logic to build reusable device scopes for automation and reporting. - - {lastEditedTs && ( - - {formatLastEditedLabel(lastEditedTs, lastEditedBy)} - - )} - + {loadingFilter ? ( + Loading filter... + ) : null} + {loadError ? ( + + {loadError} + ) : null} - + + - - - - - - {group.conditions.map((condition, cIdx) => - renderConditionRow(group.id, condition, cIdx === 0) - )} - - - - ))} - - - - - - - - Results - - Apply criteria to preview matching devices. - - {previewAppliedAt && ( - - Last applied: {previewAppliedAt.toLocaleString()} - - )} - {previewError ? ( - {previewError} - ) : null} - - - - - - + {TABS.map((tabDef) => ( + + ))} + + + + + Name + setName(e.target.value)} + placeholder="Filter name or convention (e.g., RMM targeting)" + sx={{ + width: { xs: "100%", md: "65%" }, + maxWidth: 546, + "& .MuiInputBase-root": { backgroundColor: "rgba(4,7,17,0.65)" }, + "& .MuiOutlinedInput-notchedOutline": { borderColor: AURORA_SHELL.border }, + }} /> - + + + + + Scope + + Choose whether this filter is global or pinned to a specific site. + + { + if (!val) return; + setScope(val); + }} + color="info" + sx={{ + alignSelf: "flex-start", + background: "rgba(7,12,26,0.7)", + borderRadius: 2, + "& .MuiToggleButton-root": { + textTransform: "none", + color: AURORA_SHELL.text, + borderColor: "rgba(148,163,184,0.35)", + minHeight: 32, + paddingTop: 0.25, + paddingBottom: 0.25, + paddingLeft: 1.6, + paddingRight: 1.6, + fontWeight: 700, + }, + "& .Mui-selected": { + background: TAB_HOVER_GRADIENT, + color: "#0b1220", + boxShadow: "0 0 0 1px rgba(148,163,184,0.35) inset", + }, + }} + > + Global + Site + + + {scope === "site" && ( + + + setApplyToAllSites(e.target.checked)} + color="info" + /> + + Add filter to all Sites + + Future sites will also inherit this filter when enabled. + + + + + {!applyToAllSites && ( + s.value === targetSite) || null} + getOptionLabel={(option) => option?.label || ""} + isOptionEqualToValue={(option, value) => option?.value === value?.value} + onChange={(_, val) => setTargetSite(val?.value || "")} + renderInput={(params) => ( + + )} + /> + )} + + )} + + + + + + + Criteria + + + + Add conditions inside each group, mixing AND/OR as needed. Groups themselves can be chained with AND or OR to + mirror complex targeting logic (e.g., (A AND B) OR (C AND D)). + + + {groups.map((group, idx) => ( + + {idx > 0 && ( + { + if (!val) return; + updateGroup(group.id, { ...group, joinWith: val }); + }} + color="info" + sx={{ + alignSelf: "flex-start", + "& .MuiToggleButton-root": { px: 2, textTransform: "uppercase", fontSize: "0.8rem" }, + }} + > + AND + OR + + )} + + + + Criteria Group {idx + 1} + + + + + + + + {group.conditions.map((condition, cIdx) => + renderConditionRow(group.id, condition, cIdx === 0) + )} + + + + ))} + + + + + + + + + + Results + + Apply criteria to preview matching devices. + + {previewAppliedAt && ( + + Last applied: {previewAppliedAt.toLocaleString()} + + )} + {previewError ? ( + {previewError} + ) : null} + + + + + + + + + + + {saveError ? (