From 04acd648d953b90913432ff622eff52af8cb03af Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 4 Sep 2025 16:24:26 -0500 Subject: [PATCH 01/38] [Topbar] Improved Menu UI --- frontend/src/AppLayout.tsx | 6 +++ frontend/src/components/RoleChip.tsx | 15 ++++++ frontend/src/components/Topbar.tsx | 54 +++++++++++-------- frontend/src/components/index.ts | 1 + frontend/src/views/Settings.tsx | 26 ++------- .../src/views/UserManagement/UsersTable.tsx | 16 +----- 6 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/RoleChip.tsx diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 98281aaf..9e3ecd60 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -69,6 +69,12 @@ export const AppLayout = ({ } }, [isLoggedIn, hasScopes, userScopes, location.pathname]); + const bodyElement = document.querySelector('body') + + if (bodyElement) + bodyElement.style.backgroundColor = '#333' + // bodyElement.style.backgroundColor = '#a5adb5' + return ( { + switch (role) { + case "Admin": { + return ; + } + case "Technician": { + return ; + } + default: { + return ; + } + } +} diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index a8163ee8..c4b7c4a2 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -9,12 +9,14 @@ import { Button, Box, Divider, + ListItemIcon, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import { useNavigate } from "react-router-dom"; import { useAuthUser, useSignOut } from "react-auth-kit"; import { useState } from "react"; -import { Badge, Engineering, Face, Login } from "@mui/icons-material"; +import { Badge, Engineering, Face, Login, Logout, Settings } from "@mui/icons-material"; +import { RoleChip } from "./RoleChip"; export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMenuClick: () => void; sx?: any }) { const navigate = useNavigate(); @@ -118,28 +120,27 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen - - - - Role: {role ?? "Unknown"} - - - + + Role: + + + { @@ -147,12 +148,23 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen handleMenuClose() }} > - Settings + + + + Account Settings + + + { + fullSignOut() + handleMenuClose() + }} + > + + + + Logout - { - fullSignOut() - handleMenuClose() - }}>Logout ) diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 386f9afa..a4ac4fce 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -7,6 +7,7 @@ export * from './UserSelection' export * from './CustomCardHeader' export * from './MeterRegisterSelect' export * from './RHControlled' +export * from './RoleChip' export * from './DirectionCard' export * from './MeterSelection' export * from './StatCell' diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 253fe3c6..907e6508 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -29,7 +29,7 @@ import { Assessment, Science } from '@mui/icons-material'; -import { BackgroundBox, CustomCardHeader } from "../components"; +import { BackgroundBox, CustomCardHeader, RoleChip } from "../components"; const redirectOptions = [ { value: "/", label: "Home", icon: }, @@ -56,20 +56,6 @@ const schema = yup.object().shape({ const FALLBACK_REDIRECT = "/"; -const RoleChip = ({ role }: { role: string }) => { - switch (role) { - case "Admin": { - return ; - } - case "Technician": { - return ; - } - default: { - return ; - } - } -} - const IsActiveChip = ({ active }: { active: boolean }) => { return active ? ( @@ -126,7 +112,7 @@ export const Settings = () => { return ( - + {/* User Info */} @@ -187,15 +173,11 @@ export const Settings = () => { - - Role: - + Role: - - Active: - + Active: diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index ecc38d27..1dc4484b 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -17,7 +17,7 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { RoleChip, CustomCardHeader } from "../../components"; export const UsersTable = ({ setSelectedUser, @@ -41,19 +41,7 @@ export const UsersTable = ({ headerName: "Role", width: 200, valueGetter: (_, row) => row.user_role.name, - renderCell: (params: any) => { - switch (params.value) { - case "Admin": { - return ; - } - case "Technician": { - return ; - } - default: { - return ; - } - } - }, + renderCell: (params: any) => }, { field: "disabled", From 0d7868684ef2219181b1179dce70726180a604be Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 4 Sep 2025 17:11:55 -0500 Subject: [PATCH 02/38] [NavLink] Mv to mui implementation of navlinks --- frontend/src/AppLayout.tsx | 6 --- frontend/src/components/NavLink.tsx | 80 ++++++++++++++--------------- frontend/src/sidenav.tsx | 37 ++++++------- 3 files changed, 59 insertions(+), 64 deletions(-) diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 9e3ecd60..98281aaf 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -69,12 +69,6 @@ export const AppLayout = ({ } }, [isLoggedIn, hasScopes, userScopes, location.pathname]); - const bodyElement = document.querySelector('body') - - if (bodyElement) - bodyElement.style.backgroundColor = '#333' - // bodyElement.style.backgroundColor = '#a5adb5' - return ( ; + badgeContent?: number; }) => { const location = useLocation(); - const isActive = location.pathname === route; + const currentPath = location.pathname; + const targetPath = typeof route === "string" + ? route.split("?")[0].split("#")[0] + : route.pathname ?? ""; - const content = ( - - {Icon ? ( - - ) : ( - - )} - {label} - - ); + const isActive = currentPath === targetPath; return ( - - {disabled ? ( - content - ) : ( - - {content} - - )} - + + + + {Icon ? ( + + + + ) : ( + + )} + + + + ); }; diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index dd73a22c..f92a6a69 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/material"; -import { useNavigate } from "react-router-dom"; +import { createSearchParams, useNavigate } from "react-router-dom"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { SecurityScope, WorkOrder } from "./interfaces"; @@ -41,27 +41,22 @@ export default function Sidenav({ const hasReadScope = scopes.has("read"); const hasAdminScope = scopes.has("admin"); - const userID = authUser()?.id; - - const [workOrderLabel, setWorkOrderLabel] = useState("Work Orders"); - const workOrderList = useGetWorkOrders([WorkOrderStatus.Open], { + const userId = authUser()?.id; + const roleId = authUser()?.user_role_id; + const [workOrderCount, setWorkOrderCount] = useState(0); + const openWorkOrdersQuery = useGetWorkOrders([WorkOrderStatus.Open], { refetchInterval: 45_000, refetchIntervalInBackground: true, enabled: hasReadScope && !!authUser() }); useEffect(() => { - if (workOrderList.data && userID) { - const userWorkOrders = workOrderList.data.filter( - (workOrder: WorkOrder) => workOrder.assigned_user_id === userID - ); - setWorkOrderLabel( - userWorkOrders.length > 0 - ? `Work Orders (${userWorkOrders.length})` - : "Work Orders" - ); + if (openWorkOrdersQuery.data && userId) { + setWorkOrderCount(openWorkOrdersQuery.data.filter( + (workOrder: WorkOrder) => workOrder.assigned_user_id === userId + )?.length ?? 0); } - }, [workOrderList.data, userID]); + }, [openWorkOrdersQuery.data, userId]); return ( + badgeContent={workOrderCount} /> Date: Thu, 4 Sep 2025 21:15:22 -0500 Subject: [PATCH 03/38] [sidenav] Rm unneeded search params --- frontend/src/sidenav.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index f92a6a69..a4a98548 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/material"; -import { createSearchParams, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { SecurityScope, WorkOrder } from "./interfaces"; @@ -42,7 +42,6 @@ export default function Sidenav({ const hasReadScope = scopes.has("read"); const hasAdminScope = scopes.has("admin"); const userId = authUser()?.id; - const roleId = authUser()?.user_role_id; const [workOrderCount, setWorkOrderCount] = useState(0); const openWorkOrdersQuery = useGetWorkOrders([WorkOrderStatus.Open], { refetchInterval: 45_000, @@ -137,13 +136,7 @@ export default function Sidenav({ {hasReadScope && ( <> From f2519d0358365f1fb38d10478235918d26ac9ffe Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 4 Sep 2025 22:25:01 -0500 Subject: [PATCH 04/38] [/WorkOrders] Refactor work order components --- .../src/views/WorkOrders/DeleteWorkOrder.tsx | 56 +++++++ .../views/WorkOrders/NewWorkOrderModal.tsx | 99 +++++++++++++ .../src/views/WorkOrders/WorkOrdersTable.tsx | 139 +----------------- 3 files changed, 157 insertions(+), 137 deletions(-) create mode 100644 frontend/src/views/WorkOrders/DeleteWorkOrder.tsx create mode 100644 frontend/src/views/WorkOrders/NewWorkOrderModal.tsx diff --git a/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx b/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx new file mode 100644 index 00000000..a4d4155d --- /dev/null +++ b/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { + GridActionsCellItem, + GridActionsCellItemProps, +} from "@mui/x-data-grid"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from "@mui/material"; + +export function DeleteWorkOrder({ + deleteUser, + deleteMessage, + ...props +}: GridActionsCellItemProps & { + deleteUser: () => void; + deleteMessage?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} /> + setOpen(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {deleteMessage} + + + This action cannot be undone. + + + + + + + + + ); +} diff --git a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx new file mode 100644 index 00000000..3532820b --- /dev/null +++ b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, +} from "@mui/material"; +import { + MeterListDTO, + NewWorkOrder, +} from "../../interfaces"; +import MeterSelection from "../../components/MeterSelection"; + +interface NewWorkOrderModalProps { + openNewWorkOrderModal: boolean; + closeNewWorkOrderModal: () => void; + submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; +} + +export function NewWorkOrderModal({ + openNewWorkOrderModal, + closeNewWorkOrderModal, + submitNewWorkOrder, +}: NewWorkOrderModalProps) { + const [workOrderTitle, setWorkOrderTitle] = useState(""); + const [workOrderMeter, setWorkOrderMeter] = useState< + MeterListDTO | undefined + >(); + const [meterSelectionError, setMeterSelectionError] = + useState(false); + const [titleError, setTitleError] = useState(false); + + function handleSubmit() { + if (!workOrderMeter) { + setMeterSelectionError(true); + return; + } + if (!workOrderTitle) { + setTitleError(true); + return; + } + + //If both fields are filled, submit the work order + //Create a new work order object + const newWorkOrder: NewWorkOrder = { + date_created: new Date(), + meter_id: workOrderMeter.id, + title: workOrderTitle, + }; + submitNewWorkOrder(newWorkOrder); + closeNewWorkOrderModal(); + + //Reset the form + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + } + + const handleCancel = () => { + closeNewWorkOrderModal(); + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + }; + + return ( + + Create a New Work Order + + + To create a new work order, please select a meter and title. Other + fields can be edited as needed after creation. + + + setWorkOrderTitle(event.target.value)} + error={titleError} + helperText={titleError ? "Title cannot be empty" : ""} + /> + + + + + + + ); +} diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 3056766b..0141b3b0 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -6,8 +6,6 @@ import { DataGrid, GridColDef, GridRowModel, - GridActionsCellItem, - GridActionsCellItemProps, GridRenderCellParams, GridRowId, GridFilterItem, @@ -20,155 +18,22 @@ import { useCreateWorkOrder, } from "../../service/ApiServiceNew"; import { WorkOrderStatus } from "../../enums"; -import MeterSelection from "../../components/MeterSelection"; import { Box, Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, IconButton, Stack, - TextField, } from "@mui/material"; import GridFooterWithButton from "../../components/GridFooterWithButton"; import { MeterActivity, - MeterListDTO, NewWorkOrder, SecurityScope, } from "../../interfaces"; import { useAuthUser } from "react-auth-kit"; import { Link, createSearchParams } from "react-router-dom"; - -function DeleteWorkOrder({ - deleteUser, - deleteMessage, - ...props -}: GridActionsCellItemProps & { - deleteUser: () => void; - deleteMessage?: string; -}) { - const [open, setOpen] = useState(false); - - return ( - <> - setOpen(true)} /> - setOpen(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - {deleteMessage} - - - This action cannot be undone. - - - - - - - - - ); -} - -interface NewWorkOrderModalProps { - openNewWorkOrderModal: boolean; - closeNewWorkOrderModal: () => void; - submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; -} - -function NewWorkOrderModal({ - openNewWorkOrderModal, - closeNewWorkOrderModal, - submitNewWorkOrder, -}: NewWorkOrderModalProps) { - const [workOrderTitle, setWorkOrderTitle] = useState(""); - const [workOrderMeter, setWorkOrderMeter] = useState< - MeterListDTO | undefined - >(); - const [meterSelectionError, setMeterSelectionError] = - useState(false); - const [titleError, setTitleError] = useState(false); - - function handleSubmit() { - if (!workOrderMeter) { - setMeterSelectionError(true); - return; - } - if (!workOrderTitle) { - setTitleError(true); - return; - } - - //If both fields are filled, submit the work order - //Create a new work order object - const newWorkOrder: NewWorkOrder = { - date_created: new Date(), - meter_id: workOrderMeter.id, - title: workOrderTitle, - }; - submitNewWorkOrder(newWorkOrder); - closeNewWorkOrderModal(); - - //Reset the form - setWorkOrderMeter(undefined); - setWorkOrderTitle(""); - } - - const handleCancel = () => { - closeNewWorkOrderModal(); - setWorkOrderMeter(undefined); - setWorkOrderTitle(""); - }; - - return ( - - Create a New Work Order - - - To create a new work order, please select a meter and title. Other - fields can be edited as needed after creation. - - - setWorkOrderTitle(event.target.value)} - error={titleError} - helperText={titleError ? "Title cannot be empty" : ""} - /> - - - - - - - ); -} +import { DeleteWorkOrder } from "./DeleteWorkOrder"; +import { NewWorkOrderModal } from "./NewWorkOrderModal"; export default function WorkOrdersTable() { const [workOrderFilters, setWorkOrderFilters] = useState([ From eff8fb8c6e7c1860b6324824dd341d4154b11f5e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 5 Sep 2025 10:28:17 -0500 Subject: [PATCH 05/38] [sidenav] Refactor component & update UI --- frontend/src/components/NavLink.tsx | 27 +++-- frontend/src/components/ReportsNavItem.tsx | 72 ++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/hooks/index.ts | 1 + frontend/src/hooks/useIsActiveRoute.ts | 16 +++ frontend/src/sidenav.tsx | 128 ++++++++++++++------- frontend/src/views/Reports/index.tsx | 8 +- 7 files changed, 197 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/ReportsNavItem.tsx create mode 100644 frontend/src/hooks/useIsActiveRoute.ts diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index 00a77cd2..f6541404 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -1,27 +1,24 @@ import { SvgIconProps, Badge, ListItem, ListItemButton, ListItemIcon, ListItemText } from "@mui/material"; import TableViewIcon from "@mui/icons-material/TableView"; -import { Link, useLocation, type LinkProps } from "react-router-dom"; +import { Link, type LinkProps } from "react-router-dom"; +import { useIsActiveRoute } from "../hooks"; export const NavLink = ({ disabled = false, route, label, - Icon, + icon: Icon, badgeContent, + subItem = false, }: { disabled?: boolean; route: LinkProps["to"]; label: string; - Icon?: React.ComponentType; + icon?: React.ComponentType; badgeContent?: number; + subItem?: boolean; }) => { - const location = useLocation(); - const currentPath = location.pathname; - const targetPath = typeof route === "string" - ? route.split("?")[0].split("#")[0] - : route.pathname ?? ""; - - const isActive = currentPath === targetPath; + const isActive = useIsActiveRoute(route); return ( @@ -31,6 +28,7 @@ export const NavLink = ({ to={route} disabled={disabled} sx={{ + ml: subItem ? 2 : 0, borderRadius: "10px", "&.Mui-selected": { backgroundColor: "rgb(240,240,255)", @@ -51,7 +49,14 @@ export const NavLink = ({ )} - + ); diff --git a/frontend/src/components/ReportsNavItem.tsx b/frontend/src/components/ReportsNavItem.tsx new file mode 100644 index 00000000..2de81665 --- /dev/null +++ b/frontend/src/components/ReportsNavItem.tsx @@ -0,0 +1,72 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { + Assessment, + ExpandLess, + ExpandMore, +} from "@mui/icons-material"; +import { useNavigate } from "react-router-dom"; +import { useIsActiveRoute } from "../hooks"; + +export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Dispatch> }) { + const navigate = useNavigate(); + const [clickTimer, setClickTimer] = useState(null); + const isActive = useIsActiveRoute("/reports"); + + const handleClick = () => { + if (clickTimer) { + clearTimeout(clickTimer); + setClickTimer(null); + } + const timer = setTimeout(() => { + setOpen((prev) => !prev); + setClickTimer(null); + }, 200); + setClickTimer(timer); + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + if (clickTimer) { + clearTimeout(clickTimer); + setClickTimer(null); + } + e.stopPropagation(); + setOpen(false); + navigate("/reports"); + }; + + return ( + + + + + + + {open ? : } + + + ); +} + diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index a4ac4fce..81c676f0 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -7,6 +7,7 @@ export * from './UserSelection' export * from './CustomCardHeader' export * from './MeterRegisterSelect' export * from './RHControlled' +export * from './ReportsNavItem' export * from './RoleChip' export * from './DirectionCard' export * from './MeterSelection' diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 1d6dc912..763dbad2 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,2 +1,3 @@ export * from "./useFetchWithAuth"; export * from "./useFetchST2"; +export * from "./useIsActiveRoute"; diff --git a/frontend/src/hooks/useIsActiveRoute.ts b/frontend/src/hooks/useIsActiveRoute.ts new file mode 100644 index 00000000..20d5965f --- /dev/null +++ b/frontend/src/hooks/useIsActiveRoute.ts @@ -0,0 +1,16 @@ +import { useLocation } from "react-router-dom"; + +type RouteLike = string | { pathname?: string }; + +export function useIsActiveRoute(route: RouteLike): boolean { + const location = useLocation(); + const currentPath = location.pathname; + + // normalize target path (strip query & hash) + const targetPath = + typeof route === "string" + ? route.split("?")[0].split("#")[0] + : route.pathname ?? ""; + + return currentPath === targetPath; +} diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index a4a98548..94e66fdf 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,12 +1,18 @@ import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/material"; +import { + Box, + Collapse, + Drawer, + Grid, + IconButton, + List, + ListSubheader, + Toolbar, + Typography +} from "@mui/material"; import { useNavigate } from "react-router-dom"; -import { useGetWorkOrders } from "./service/ApiServiceNew"; -import { WorkOrderStatus } from "./enums"; -import { SecurityScope, WorkOrder } from "./interfaces"; import { - Assessment, Build, ChevronLeft, Construction, @@ -18,7 +24,14 @@ import { Science, ScreenshotMonitor, } from "@mui/icons-material"; -import { NavLink } from "./components/NavLink"; +import { + NavLink, + ReportsNavItem, + RoleChip +} from "./components"; +import { useGetWorkOrders } from "./service/ApiServiceNew"; +import { WorkOrderStatus } from "./enums"; +import { SecurityScope, WorkOrder } from "./interfaces"; export default function Sidenav({ open, @@ -29,6 +42,7 @@ export default function Sidenav({ drawerWidth: number; onClose: () => void; }) { + const [openReportsMenu, setOpenReportsMenu] = useState(true); const navigate = useNavigate(); const authUser = useAuthUser(); @@ -127,41 +141,73 @@ export default function Sidenav({ px: "1rem", }} > - -
Pages
-
- - - - {hasReadScope && ( - <> - - - - - - - - )} - - {hasAdminScope && ( - <> - -
Admin Management
-
- - - - - )} + + Pages + + }> + + {hasReadScope && ( + <> + + + + + + + + + + + + + + + + + )} + {hasAdminScope && ( + <> + + Pages + + + + + + )} +
); diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index c7b8c69c..c8d39e24 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -28,17 +28,17 @@ export const ReportsView = () => { {/* {
From e537917b51b4dd776309382da3892b71414e53f3 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 5 Sep 2025 10:57:12 -0500 Subject: [PATCH 06/38] [IsTrueChip] Refactor UI for chip consistency --- frontend/src/components/IsTrueChip.tsx | 9 +++ frontend/src/components/RoleChip.tsx | 6 +- frontend/src/components/index.ts | 1 + frontend/src/views/Parts/MeterTypesTable.tsx | 10 +-- frontend/src/views/Parts/PartsTable.tsx | 16 +---- frontend/src/views/Settings.tsx | 69 +++---------------- .../src/views/UserManagement/UsersTable.tsx | 9 +-- 7 files changed, 31 insertions(+), 89 deletions(-) create mode 100644 frontend/src/components/IsTrueChip.tsx diff --git a/frontend/src/components/IsTrueChip.tsx b/frontend/src/components/IsTrueChip.tsx new file mode 100644 index 00000000..1b19d20b --- /dev/null +++ b/frontend/src/components/IsTrueChip.tsx @@ -0,0 +1,9 @@ +import { Chip } from "@mui/material"; + +export const IsTrueChip = ({ assert }: { assert: boolean }) => { + return assert ? ( + + ) : ( + + ); +} diff --git a/frontend/src/components/RoleChip.tsx b/frontend/src/components/RoleChip.tsx index e2bf8d8b..431e40b1 100644 --- a/frontend/src/components/RoleChip.tsx +++ b/frontend/src/components/RoleChip.tsx @@ -3,13 +3,13 @@ import { Chip } from "@mui/material"; export const RoleChip = ({ role }: { role: string }) => { switch (role) { case "Admin": { - return ; + return ; } case "Technician": { - return ; + return ; } default: { - return ; + return ; } } } diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 81c676f0..c76d2fd0 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -9,6 +9,7 @@ export * from './MeterRegisterSelect' export * from './RHControlled' export * from './ReportsNavItem' export * from './RoleChip' +export * from './IsTrueChip' export * from './DirectionCard' export * from './MeterSelection' export * from './StatCell' diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index b57e4621..9ece1568 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - Chip, Grid, InputAdornment, Stack, @@ -18,7 +17,7 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import { MeterTypeLU } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { IsTrueChip, CustomCardHeader } from "../../components"; export const MeterTypesTable = ({ setSelectedMeterType, @@ -45,12 +44,7 @@ export const MeterTypesTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => - params.value == true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, ]; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 1ed7a630..8d4f74bc 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - Chip, Grid, InputAdornment, Stack, @@ -19,6 +18,7 @@ import { Part } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { IsTrueChip } from "../../components"; export const PartsTable = ({ setSelectedPartID, @@ -46,22 +46,12 @@ export const PartsTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => - params.value == true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, { field: "commonly_used", headerName: "Commonly Used", - renderCell: (params: any) => - params.value == true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, ]; diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 907e6508..44a8cd2d 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -29,7 +29,7 @@ import { Assessment, Science } from '@mui/icons-material'; -import { BackgroundBox, CustomCardHeader, RoleChip } from "../components"; +import { BackgroundBox, CustomCardHeader, IsTrueChip, RoleChip } from "../components"; const redirectOptions = [ { value: "/", label: "Home", icon: }, @@ -56,14 +56,6 @@ const schema = yup.object().shape({ const FALLBACK_REDIRECT = "/"; -const IsActiveChip = ({ active }: { active: boolean }) => { - return active ? ( - - ) : ( - - ); -} - export const Settings = () => { const authUser = useAuthUser(); const [savedMessage, setSavedMessage] = useState(""); @@ -121,56 +113,17 @@ export const Settings = () => { - - - Full Name:{" "} - - {user?.full_name ?? "N/A"} - - + + Full Name: + - - - Email:{" "} - - {user?.email ?? "N/A"} - - + + Email: + - - - Username:{" "} - - {user?.username ?? "N/A"} - - + + Username: + Role: @@ -178,7 +131,7 @@ export const Settings = () => { Active: - + diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 1dc4484b..9ff56d1e 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -17,7 +17,7 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { RoleChip, CustomCardHeader } from "../../components"; +import { RoleChip, CustomCardHeader, IsTrueChip } from "../../components"; export const UsersTable = ({ setSelectedUser, @@ -46,12 +46,7 @@ export const UsersTable = ({ { field: "disabled", headerName: "Active", - renderCell: (params: any) => - params.value != true ? ( - - ) : ( - - ), + renderCell: (params: any) => }, ]; From d431011c89b08e2398393bacc558f0939ec42dfe Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 5 Sep 2025 12:27:16 -0500 Subject: [PATCH 07/38] [views/Activities] Refactor entire form UI to improve it on tablets --- .../src/components/ImageUploadWithPreview.tsx | 78 +++++++++++ .../RHControlled/ControlledTimepicker.tsx | 7 +- .../src/components/StyledToggleButton.tsx | 28 ++++ frontend/src/components/index.ts | 2 + .../src/views/Activities/ActivitiesView.tsx | 5 - .../MaintenanceRepairSelection.tsx | 22 ++- .../MeterActivityEntry/MeterInstallation.tsx | 30 ++-- .../MeterActivityEntry/NotesSelection.tsx | 128 ++++++++---------- .../ObservationsSelection.tsx | 92 ++++++------- .../MeterActivityEntry/PartsSelection.tsx | 80 ++++++----- 10 files changed, 281 insertions(+), 191 deletions(-) create mode 100644 frontend/src/components/ImageUploadWithPreview.tsx create mode 100644 frontend/src/components/StyledToggleButton.tsx diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx new file mode 100644 index 00000000..7070dcd7 --- /dev/null +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import { Grid, Button, Box, Typography } from "@mui/material"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; + +const VisuallyHiddenInput = (props: any) => ( + +); + +export const ImageUploadWithPreview = () => { + const [previews, setPreviews] = useState([]); + + const handleFileChange = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files) return; + + // filter to images only + const imageFiles = Array.from(files).filter((file) => + file.type.startsWith("image/") + ); + + // create preview URLs + const urls = imageFiles.map((file) => URL.createObjectURL(file)); + + setPreviews(urls); + }; + + return ( + + + + {previews.length > 0 && ( + + + Preview: + + + {previews.map((src, i) => ( + + ))} + + + )} + + ); +} diff --git a/frontend/src/components/RHControlled/ControlledTimepicker.tsx b/frontend/src/components/RHControlled/ControlledTimepicker.tsx index 25d0025e..53f2cd85 100644 --- a/frontend/src/components/RHControlled/ControlledTimepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledTimepicker.tsx @@ -14,7 +14,12 @@ export default function ControlledTimepicker({ )} diff --git a/frontend/src/components/StyledToggleButton.tsx b/frontend/src/components/StyledToggleButton.tsx new file mode 100644 index 00000000..d80fc909 --- /dev/null +++ b/frontend/src/components/StyledToggleButton.tsx @@ -0,0 +1,28 @@ +import { ToggleButton, ToggleButtonProps } from "@mui/material"; + +const defaultToggleStyle = { + "&.Mui-selected": { borderColor: "blue", border: 1 }, +}; + +export const StyledToggleButton = (props: ToggleButtonProps) => { + const { + children, + value = "check", + color = "primary", + fullWidth = true, + sx, + ...rest + } = props; + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index c76d2fd0..1537a894 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -13,6 +13,7 @@ export * from './IsTrueChip' export * from './DirectionCard' export * from './MeterSelection' export * from './StatCell' +export * from './StyledToggleButton' export * from './WellSelection' export * from './MeterTypeSelect' export * from './TabPanel' @@ -22,3 +23,4 @@ export * from './GridFooterWithButton' export * from './NavLink' export * from './Topbar' export * from './WellMapLegend' +export * from './ImageUploadWithPreview' diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index c538847f..e7b41bce 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -4,11 +4,6 @@ import { Construction } from "@mui/icons-material"; import { BackgroundBox } from "../../components/BackgroundBox"; import { CustomCardHeader } from "../../components/CustomCardHeader"; -export const gridBreakpoints = { xs: 12 }; -export const toggleStyle = { - "&.Mui-selected": { borderColor: "blue", border: 1 }, -}; - export const ActivitiesView = () => { return ( diff --git a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx index 62978404..d8a7dfff 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx @@ -1,8 +1,7 @@ -import { Box, Grid } from "@mui/material"; -import ToggleButton from "@mui/material/ToggleButton"; +import { Box, Grid, Typography } from "@mui/material"; import { useFieldArray } from "react-hook-form"; import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { gridBreakpoints, toggleStyle } from "../ActivitiesView"; +import { StyledToggleButton } from "../../../components"; import { useGetServiceTypes } from "../../../service/ApiServiceNew"; export default function MaintenanceRepairSelection({ @@ -30,25 +29,24 @@ export default function MaintenanceRepairSelection({ const selectItem = (ID: number) => append(ID); const MaintanenceToggleButton = ({ item }: any) => ( - - + { isSelected(item.id) ? unselectItem(item.id) : selectItem(item.id); }} - sx={toggleStyle} > {item.service_name} - + ); return ( -

Maintanence/Repair

+ + Maintanence/Repair + {serviceTypes.isLoading ? ( @@ -56,14 +54,14 @@ export default function MaintenanceRepairSelection({ ) : ( - + {serviceTypes.data?.map((item: any) => { return ; })} )} - + -

+ + Current Installation -

+ @@ -66,12 +62,12 @@ export default function MeterInstallation({ control, errors, watch }: any) { {watch("current_installation.well")?.location?.latitude == - null + null ? "--" : formatLatLong( - watch("current_installation.well")?.location?.latitude, - watch("current_installation.well")?.location?.longitude, - )} + watch("current_installation.well")?.location?.latitude, + watch("current_installation.well")?.location?.longitude, + )} {watch("current_installation.well")?.osetag ?? "--"} @@ -82,7 +78,7 @@ export default function MeterInstallation({ control, errors, watch }: any) { - + - + - + - + - + append(ID); const NoteToggleButton = ({ note }: any) => ( - - + { isSelected(note.id) ? unselectNote(note.id) : selectNote(note.id); }} - sx={toggleStyle} + sx={{ flexGrow: 1, height: "100%" }} > {note.note} - + ); return ( -

Notes

+ + Notes + - + ( - - + + Working Status Not Checked - - + + Meter Working On Arrival - - + + Meter Not Working On Arrival - + )} /> - - - {notesList.data?.map((note: any) => { - if (visibleNoteIDs.some((x) => x == note.id)) { - return ; - } - })} - + + {notesList.data?.map((note: any) => { + if (visibleNoteIDs.some((x) => x == note.id)) { + return ; + } + })} - - - - Add Other Notes - { + setVisibleNoteIDs( + produce(visibleNoteIDs, (newNotes) => { + newNotes.push(event.target.value); + }), + ); + selectNote(event.target.value); + }} + > + {notesList.data?.map((nt: NoteTypeLU) => { + if ( + !visibleNoteIDs.some((x) => x == nt.id) && + ![ + WorkingOnArrivalValue.Working, + WorkingOnArrivalValue.NotWorking, + WorkingOnArrivalValue.NotChecked, + ].some((x) => x == nt.slug) + ) { + return ( + + {nt.note} + ); - selectNote(event.target.value); - }} - > - {notesList.data?.map((nt: NoteTypeLU) => { - if ( - !visibleNoteIDs.some((x) => x == nt.id) && - ![ - WorkingOnArrivalValue.Working, - WorkingOnArrivalValue.NotWorking, - WorkingOnArrivalValue.NotChecked, - ].some((x) => x == nt.slug) - ) { - return ( - - {nt.note} - - ); - } - })} - - - + } + })} + + + + +
diff --git a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx index b230bc01..ca3890db 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx @@ -1,11 +1,11 @@ import { useEffect } from "react"; -import { Box, Button, Grid } from "@mui/material"; +import { Box, Button, Grid, Typography } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import IconButton from "@mui/material/IconButton"; +import { useFieldArray } from "react-hook-form"; import dayjs from "dayjs"; import { ObservedPropertyTypeLU } from "../../../interfaces"; import { useGetPropertyTypes } from "../../../service/ApiServiceNew"; -import { useFieldArray } from "react-hook-form"; import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; import { ControlledSelect } from "../../../components/RHControlled/ControlledSelect"; @@ -35,52 +35,50 @@ function ObservationRow({ ]); // Update the selected unit to the first in the newly selected property type return ( - + {!propertyTypes.isLoading && ( <> - - - - - - p.name} - error={errors?.observations?.at(index)?.property_type?.message} - /> - - - - - - p.name} - error={errors?.observations?.at(index)?.unit?.message} - /> - + + + + + p.name} + error={errors?.observations?.at(index)?.property_type?.message} + /> + + + + + + p.name} + error={errors?.observations?.at(index)?.unit?.message} + /> - + remove(index)} @@ -119,7 +117,9 @@ export default function ObservationSelection({ return ( -

Observations

+ + Observations + {fields.map((field, index) => { return ( diff --git a/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx index a35884f0..b0d65bcd 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx @@ -7,11 +7,11 @@ import { InputLabel, Select, MenuItem, + Typography, } from "@mui/material"; -import ToggleButton from "@mui/material/ToggleButton"; import { useFieldArray } from "react-hook-form"; -import { gridBreakpoints, toggleStyle } from "../ActivitiesView"; import { Part } from "../../../interfaces"; +import { StyledToggleButton } from "../../../components"; import { useGetMeterPartsList } from "../../../service/ApiServiceNew"; export default function PartsSelection({ control, watch, setValue }: any) { @@ -45,29 +45,29 @@ export default function PartsSelection({ control, watch, setValue }: any) { const selectPart = (ID: number) => append(ID); const PartToggleButton = ({ part }: { part: Part }) => ( - - + { isSelected(part.id) ? unselectPart(part.id) : selectPart(part.id); }} - sx={toggleStyle} + sx={{ flexGrow: 1, height: "100%" }} key={part.id} > {`${part.part_type?.name} - ${part.description} (${part.part_number})`} - + ); return ( -

Parts Used

+ + Parts Used + - + {partsList.data ?.filter((p: Part) => p.in_use) .map((p: Part) => { @@ -77,37 +77,35 @@ export default function PartsSelection({ control, watch, setValue }: any) { })} - - - - Add Other Parts - - - + + + Add Other Parts + +
From 829f589a7ccb53a76aa1ee9e31a79960d42ebca2 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 5 Sep 2025 12:39:33 -0500 Subject: [PATCH 08/38] [ImageUploadWithPreview] Improve image previews --- .../src/components/ImageUploadWithPreview.tsx | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx index 7070dcd7..7f66656f 100644 --- a/frontend/src/components/ImageUploadWithPreview.tsx +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import { Grid, Button, Box, Typography } from "@mui/material"; +import { Grid, Button, Box, Typography, IconButton } from "@mui/material"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import CloseIcon from "@mui/icons-material/Close"; const VisuallyHiddenInput = (props: any) => ( { const files = event.target.files; if (!files) return; - // filter to images only const imageFiles = Array.from(files).filter((file) => file.type.startsWith("image/") ); - // create preview URLs const urls = imageFiles.map((file) => URL.createObjectURL(file)); + setPreviews((prev) => [...prev, ...urls]); // append instead of replace - setPreviews(urls); + event.target.value = ""; + }; + + const handleRemove = (index: number) => { + setPreviews((prev) => { + const updated = [...prev]; + const [removed] = updated.splice(index, 1); + URL.revokeObjectURL(removed); // free memory + return updated; + }); }; return ( @@ -46,7 +55,7 @@ export const ImageUploadWithPreview = () => { {previews.length > 0 && ( - Preview: + Preview{(previews?.length ?? 0) >= 2 ? "s" : null}: { {previews.map((src, i) => ( + > + + handleRemove(i)} + sx={{ + position: "absolute", + top: 0, + right: 0, + backgroundColor: "rgba(255,255,255,0.7)", + border: '1px solid black', + "&:hover": { backgroundColor: "rgba(255,0,0,0.8)", color: "white" }, + }} + > + + + ))} @@ -76,3 +108,4 @@ export const ImageUploadWithPreview = () => {
); } + From 9502a01f7aa9a6c2efb261a9d0f312573586047c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 5 Sep 2025 12:48:38 -0500 Subject: [PATCH 09/38] [ImageUploadWithPreview] Add dblclick feat to preview full image on it --- .../src/components/ImageUploadWithPreview.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx index 7f66656f..88c97eb9 100644 --- a/frontend/src/components/ImageUploadWithPreview.tsx +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Grid, Button, Box, Typography, IconButton } from "@mui/material"; +import { Grid, Button, Box, Typography, IconButton, Dialog, DialogContent } from "@mui/material"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloseIcon from "@mui/icons-material/Close"; @@ -12,6 +12,18 @@ const VisuallyHiddenInput = (props: any) => ( export const ImageUploadWithPreview = () => { const [previews, setPreviews] = useState([]); + const [open, setOpen] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + + const handleOpen = (src: string) => { + setSelectedImage(src); + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + setSelectedImage(null); + }; const handleFileChange = (event: React.ChangeEvent) => { const files = event.target.files; @@ -75,6 +87,7 @@ export const ImageUploadWithPreview = () => { border: "1px solid #ddd", overflow: "hidden", }} + onDoubleClick={() => handleOpen(src)} > {
))} + + + + + + {selectedImage && ( + + )} + + )} From d2abb755b8954e4f8cec43d37a55b329217d804d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 5 Sep 2025 12:53:56 -0500 Subject: [PATCH 10/38] Rm unused import --- frontend/src/views/UserManagement/UsersTable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 9ff56d1e..ac4f74f1 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - Chip, Grid, InputAdornment, TextField, From b9c5f604d14350045427f5fc43d747d5a24da0d4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sun, 7 Sep 2025 18:58:34 -0500 Subject: [PATCH 11/38] [topbar] Add dicebear avatars & update routes --- frontend/package-lock.json | 418 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/App.tsx | 74 ++-- frontend/src/components/AvatarPicker.tsx | 88 ++++ frontend/src/components/Topbar.tsx | 38 +- frontend/src/constants.ts | 46 ++ frontend/src/sidenav.tsx | 76 ++-- frontend/src/utils/GetRoleColor.ts | 12 + frontend/src/utils/index.ts | 1 + .../MeterActivityEntry/MeterActivityEntry.tsx | 2 +- frontend/src/views/NotFound.tsx | 31 ++ frontend/src/views/Reports/index.tsx | 2 +- frontend/src/views/Settings.tsx | 195 ++++++-- .../src/views/WorkOrders/WorkOrdersTable.tsx | 4 +- 14 files changed, 841 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/AvatarPicker.tsx create mode 100644 frontend/src/utils/GetRoleColor.ts create mode 100644 frontend/src/views/NotFound.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9ea3c7e9..d73665f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,8 @@ "license": "Apache-2.0", "dependencies": { "@changey/react-leaflet-markercluster": "^4.0.0-rc1", + "@dicebear/collection": "^9.2.4", + "@dicebear/core": "^9.2.4", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", @@ -225,6 +227,422 @@ "findup": "bin/findup.js" } }, + "node_modules/@dicebear/adventurer": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.4.tgz", + "integrity": "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/adventurer-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.4.tgz", + "integrity": "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.4.tgz", + "integrity": "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.4.tgz", + "integrity": "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.4.tgz", + "integrity": "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.4.tgz", + "integrity": "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.4.tgz", + "integrity": "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.4.tgz", + "integrity": "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.4.tgz", + "integrity": "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/collection": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.4.tgz", + "integrity": "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA==", + "license": "MIT", + "dependencies": { + "@dicebear/adventurer": "9.2.4", + "@dicebear/adventurer-neutral": "9.2.4", + "@dicebear/avataaars": "9.2.4", + "@dicebear/avataaars-neutral": "9.2.4", + "@dicebear/big-ears": "9.2.4", + "@dicebear/big-ears-neutral": "9.2.4", + "@dicebear/big-smile": "9.2.4", + "@dicebear/bottts": "9.2.4", + "@dicebear/bottts-neutral": "9.2.4", + "@dicebear/croodles": "9.2.4", + "@dicebear/croodles-neutral": "9.2.4", + "@dicebear/dylan": "9.2.4", + "@dicebear/fun-emoji": "9.2.4", + "@dicebear/glass": "9.2.4", + "@dicebear/icons": "9.2.4", + "@dicebear/identicon": "9.2.4", + "@dicebear/initials": "9.2.4", + "@dicebear/lorelei": "9.2.4", + "@dicebear/lorelei-neutral": "9.2.4", + "@dicebear/micah": "9.2.4", + "@dicebear/miniavs": "9.2.4", + "@dicebear/notionists": "9.2.4", + "@dicebear/notionists-neutral": "9.2.4", + "@dicebear/open-peeps": "9.2.4", + "@dicebear/personas": "9.2.4", + "@dicebear/pixel-art": "9.2.4", + "@dicebear/pixel-art-neutral": "9.2.4", + "@dicebear/rings": "9.2.4", + "@dicebear/shapes": "9.2.4", + "@dicebear/thumbs": "9.2.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz", + "integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.11" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.4.tgz", + "integrity": "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/croodles-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.4.tgz", + "integrity": "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/dylan": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.4.tgz", + "integrity": "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.4.tgz", + "integrity": "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/glass": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.4.tgz", + "integrity": "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/icons": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.4.tgz", + "integrity": "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.4.tgz", + "integrity": "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz", + "integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.4.tgz", + "integrity": "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.4.tgz", + "integrity": "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.4.tgz", + "integrity": "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/miniavs": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.4.tgz", + "integrity": "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.4.tgz", + "integrity": "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.4.tgz", + "integrity": "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/open-peeps": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.4.tgz", + "integrity": "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/personas": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.4.tgz", + "integrity": "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.4.tgz", + "integrity": "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art-neutral": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.4.tgz", + "integrity": "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.4.tgz", + "integrity": "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.4.tgz", + "integrity": "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.4.tgz", + "integrity": "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6650a1e8..a553f1d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@changey/react-leaflet-markercluster": "^4.0.0-rc1", + "@dicebear/collection": "^9.2.4", + "@dicebear/core": "^9.2.4", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 24bdd003..407bc945 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import { PartsUsedReportView } from "./views/Reports/PartsUsed"; import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; import { AppLayout } from "./AppLayout"; +import { NotFound } from "./views/NotFound"; export const App = () => { const queryClient = new QueryClient(); @@ -67,13 +68,16 @@ export const App = () => { /> } /> - } - requiredScopes={[]} - setErrorMessage={setErrorMessage} - /> - } /> + } + requiredScopes={[]} + setErrorMessage={setErrorMessage} + /> + } + /> { } /> } + pageComponent={} requiredScopes={["read"]} setErrorMessage={setErrorMessage} /> @@ -105,7 +109,7 @@ export const App = () => { } /> } @@ -114,6 +118,26 @@ export const App = () => { /> } /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> { } /> } @@ -195,7 +219,7 @@ export const App = () => { } /> } @@ -205,7 +229,7 @@ export const App = () => { } /> } @@ -214,32 +238,12 @@ export const App = () => { /> } /> - } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> - } - /> - } - requiredScopes={["read"]} - setErrorMessage={setErrorMessage} - /> - } - /> } - requiredScopes={["read"]} + pageComponent={} + requiredScopes={[]} setErrorMessage={setErrorMessage} /> } diff --git a/frontend/src/components/AvatarPicker.tsx b/frontend/src/components/AvatarPicker.tsx new file mode 100644 index 00000000..ab77cdf5 --- /dev/null +++ b/frontend/src/components/AvatarPicker.tsx @@ -0,0 +1,88 @@ +import { useState, useEffect } from "react"; +import { Grid, Box, Card, CardActionArea, Button, Typography } from "@mui/material"; +import { createAvatar } from "@dicebear/core"; +import { identicon } from "@dicebear/collection"; + +type AvatarPickerProps = { + onSelect: (avatar: string) => void; // returns selected avatar as data URI + initialSeed?: string; +}; + +export default function AvatarPicker({ onSelect, initialSeed }: AvatarPickerProps) { + const [avatars, setAvatars] = useState([]); + const [selected, setSelected] = useState(null); + + const generateAvatars = () => { + const batch = Array.from({ length: 12 }, () => { + const seed = Math.random().toString(36).substring(2, 10); // random seed + return createAvatar(identicon, { + size: 64, + seed, + }).toDataUri(); + }); + setAvatars(batch); + setSelected(null); // reset selection when new batch generated + }; + + // Generate initial batch on mount + useEffect(() => { + generateAvatars(); + }, []); + + // If initialSeed provided, generate that avatar as the selected one + useEffect(() => { + if (initialSeed) { + const avatar = createAvatar(identicon, { + size: 64, + seed: initialSeed, + }).toDataUri(); + setSelected(avatar); + } + }, [initialSeed]); + + const handleSelect = (avatar: string) => { + setSelected(avatar); + onSelect(avatar); + }; + + return ( + + + Choose Your Avatar + + + {avatars.map((avatar, i) => ( + + + handleSelect(avatar)}> + + + + + ))} + + + + + + + ); +} diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index c4b7c4a2..4d23828f 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,3 +1,4 @@ +import { useState, useMemo } from "react"; import { AppBar, Toolbar, @@ -14,16 +15,24 @@ import { import MenuIcon from "@mui/icons-material/Menu"; import { useNavigate } from "react-router-dom"; import { useAuthUser, useSignOut } from "react-auth-kit"; -import { useState } from "react"; -import { Badge, Engineering, Face, Login, Logout, Settings } from "@mui/icons-material"; +import { createAvatar } from '@dicebear/core'; +import { identicon } from '@dicebear/collection'; +import { Login, Logout, Settings } from "@mui/icons-material"; import { RoleChip } from "./RoleChip"; +import { getRoleColor } from "../utils"; export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMenuClick: () => void; sx?: any }) { const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); - const role = authUser()?.user_role?.name; + const role: string = authUser()?.user_role?.name; const isLoggedIn = !!authUser(); + const avatar = useMemo(() => { + return createAvatar(identicon, { + size: 128, + seed: authUser()?.full_name + }).toDataUri(); + }, []); const [anchorEl, setAnchorEl] = useState(null); @@ -40,17 +49,6 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen signOut(); }; - const renderRoleIcon = () => { - switch (role) { - case "Admin": - return ; - case "Technician": - return ; - default: - return ; - } - }; - return ( navigate("/login")} sx={{ textTransform: "uppercase", + fontFamily: "monospace", fontWeight: "bolder", backgroundColor: "darkblue", color: "white", diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index bf8eaaba..a9ce68f0 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,3 +1,49 @@ +import { + Home as HomeIcon, + FormatListBulletedOutlined, + ScreenshotMonitor, + Construction, + MonitorHeart, + Plumbing, + Build, + Science, + People, +} from "@mui/icons-material"; +import { SvgIconProps } from "@mui/material"; +import { ComponentType } from "react"; + +type NavItem = { + path: string; + label: string; + icon: ComponentType; + role?: "Technician" | "Admin"; // restrict by role + parent?: string; // e.g. "reports" + badge?: () => number | undefined; // function for live counts +}; + +export const navConfig: NavItem[] = [ + { path: "/", label: "Home", icon: HomeIcon }, + + // Technician + { path: "/workorders", label: "Work Orders", icon: FormatListBulletedOutlined, role: "Technician" }, + { path: "/activities", label: "Activities", icon: Construction, role: "Technician" }, + { path: "/manage/meters", label: "Manage Meters", icon: ScreenshotMonitor, role: "Technician" }, + { path: "/manage/wells", label: "Manage Wells", icon: Plumbing, role: "Technician" }, + { path: "/monitoringwells", label: "Monitoring Wells", icon: MonitorHeart, role: "Technician" }, + + // Reports + { path: "/reports/monitoringwells", label: "Monitoring Wells", icon: MonitorHeart, role: "Technician", parent: "reports" }, + { path: "/reports/maintenance", label: "Maintenance", icon: Construction, role: "Technician", parent: "reports" }, + { path: "/reports/partsused", label: "Parts Used", icon: Build, role: "Technician", parent: "reports" }, + { path: "/reports/chlorides", label: "Chlorides", icon: Science, role: "Technician", parent: "reports" }, + + + // Admin + { path: "/manage/parts", label: "Manage Parts", icon: Build, role: "Admin" }, + { path: "/manage/users", label: "Manage Users", icon: People, role: "Admin" }, + { path: "/chlorides", label: "Chlorides", icon: Science, role: "Admin" }, +]; + export const PM_COLORS: { [key: string]: string } = { "2020/2021": "brown", "2021/2022": "green", diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index 94e66fdf..c31632d5 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -32,6 +32,7 @@ import { import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { SecurityScope, WorkOrder } from "./interfaces"; +import { navConfig } from "./constants"; export default function Sidenav({ open, @@ -147,52 +148,41 @@ export default function Sidenav({ Pages }> - + {navConfig + .filter(item => !item.role) + .map(item => ( + + ))} {hasReadScope && ( <> -
- + - + - - + - + - - - - - + + + - - - - - - - - - - - + + - - - - - - - - - - - + + - - + + + + + + + + + + + + + - - + - - + - - + - - + - - + ); diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 09a0aff3..e56aeaf0 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -30,7 +30,7 @@ import { Edit, ExpandMore } from '@mui/icons-material'; -import { BackgroundBox, CustomCardHeader, IsTrueChip, RoleChip } from "../components"; +import { BackgroundBox, CustomCardHeader, ImageUploadWithPreview, IsTrueChip, RoleChip } from "../components"; import { navConfig } from '../constants'; import { useFetchWithAuth } from '../hooks'; import { useMutation, useQuery, useQueryClient } from 'react-query'; @@ -298,6 +298,18 @@ export const Settings = () => { + + }> + Avatar Configuration + + + + + + + + + }> Redirect Page After Login From 484d7bf1df8ae224865416580cd099a38111fef7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 15 Sep 2025 22:16:53 -0500 Subject: [PATCH 31/38] [views/UserManagement] Add display name to user management --- frontend/src/interfaces.d.ts | 2 +- .../views/UserManagement/UserDetailsCard.tsx | 253 +++++++++--------- .../UserManagement/UserManagementView.tsx | 1 - .../src/views/UserManagement/UsersTable.tsx | 2 +- 4 files changed, 124 insertions(+), 134 deletions(-) diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index 09b93637..de06ea97 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -604,11 +604,11 @@ export interface User { id: number username?: string full_name: string + display_name?: string email?: scope_string disabled: boolean user_role_id?: number user_role?: UserRole - password?: string } diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 6c94b39e..1bcf638d 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -36,28 +36,32 @@ import { import { CustomCardHeader } from "../../components/CustomCardHeader"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ - username: Yup.string().required("Please enter a username."), full_name: Yup.string().required("Please enter a full name."), + display_name: Yup.string().required("Please enter a display name."), + username: Yup.string().required("Please enter a username."), email: Yup.string().required("Please enter an email."), disabled: Yup.boolean().required("Please indicate if user is active."), user_role: Yup.object().required("Please indicate the users role."), password: Yup.string(), }); -// Format the submission as the backend schema specifies -function formatSubmission(user: User) { +const formatSubmission = (user: User) => { let formattedUser = user; formattedUser.user_role_id = user.user_role?.id; delete formattedUser.user_role; return formattedUser; } -function SetNewPasswordAccordion({ control, errorMessage, handleSubmit }: any) { +const SetNewPasswordAccordion = ({ + control, + errorMessage, + handleSubmit +}: any) => { return ( - + } - sx={{ m: 0, ml: 1, mr: 1, p: 0, color: "#595959" }} + sx={{ m: 0, mx: 2, p: 0, color: "#595959" }} > {" "}   @@ -65,7 +69,7 @@ function SetNewPasswordAccordion({ control, errorMessage, handleSubmit }: any) { - + - + @@ -87,22 +91,14 @@ function SetNewPasswordAccordion({ control, errorMessage, handleSubmit }: any) { ); } -interface UserDetailsCardProps { - selectedUser: User | undefined; - userAddMode: boolean; -} - -// Handles adding, updating and changing the password of a user -// If updating a user password, a special endpoint is called -// When updating or creating a user, the values are validated, then the submit handler is called -// Any validation not in the validation schema must be checked in the submit handler export const UserDetailsCard = ({ selectedUser, userAddMode, -}: UserDetailsCardProps) => { +}: { + selectedUser?: User; + userAddMode: boolean; +}) => { const rolesList = useGetRoles(); - - // React hook form for user field values const { handleSubmit, control, @@ -114,31 +110,24 @@ export const UserDetailsCard = ({ resolver: yupResolver(UserResolverSchema), }); - // Submission callbacks - function onSuccessfulUpdate() { + const onSuccessfulUpdate = () => enqueueSnackbar("Successfully Updated User!", { variant: "success" }); - } - function onSuccessfulPasswordUpdate() { - enqueueSnackbar("Successfully Updated User's Password!", { - variant: "success", - }); - } - function onSuccessfulCreate() { + const onSuccessfulPasswordUpdate = () => + enqueueSnackbar("Successfully Updated User's Password!", { variant: "success" }); + const onSuccessfulCreate = () => { enqueueSnackbar("Successfully Created New User!", { variant: "success" }); reset(); } + const onErr = (data: any) => console.error("ERR: ", data); const updateUser = useUpdateUser(onSuccessfulUpdate); const createUser = useCreateUser(onSuccessfulCreate); const updateUserPassword = useUpdateUserPassword(onSuccessfulPasswordUpdate); - // Submit handlers - function onSaveChanges(user: User) { - updateUser.mutate(formatSubmission(user)); - } + const onSaveChanges = (user: User) => updateUser.mutate(formatSubmission(user)); - function onCreateUser(user: User) { + const onCreateUser = (user: User) => { if (!user.password || user.password.length < 1) { enqueueSnackbar("Please provide a password.", { variant: "error" }); return; @@ -146,10 +135,10 @@ export const UserDetailsCard = ({ createUser.mutate(formatSubmission(user)); } - function onUpdateUserPassword( + const onUpdateUserPassword = ( userId: number, newPassword: string | undefined, - ) { + ) => { if (!newPassword || newPassword.length < 1) { enqueueSnackbar("Please provide a new password.", { variant: "error" }); return; @@ -161,7 +150,6 @@ export const UserDetailsCard = ({ updateUserPassword.mutate(updatedUserPassword); } - // Populate the form with the selected user's details useEffect(() => { if (selectedUser != undefined) { reset(); @@ -171,12 +159,10 @@ export const UserDetailsCard = ({ } }, [selectedUser]); - // Empty the form if entering user add mode useEffect(() => { if (userAddMode) reset(); }, [userAddMode]); - // Determine if form is valid, {errors} in useEffect or formState's isValid don't work const hasErrors = () => Object.keys(errors).length > 0; return ( @@ -186,105 +172,110 @@ export const UserDetailsCard = ({ icon={userAddMode ? AddIcon : EditIcon} /> - - - - - - - - - - - + + + + + + + + + + + + + + + (label ? "False" : "True")} + error={errors?.disabled?.message} + /> + + + role.name} + control={control} + error={errors?.user_role?.message} + /> + + + {userAddMode ? ( - - - (label ? "False" : "True")} - error={errors?.disabled?.message} + label="Password" + error={errors?.password?.message != undefined} + helperText={errors?.password?.message} /> - - - role.name} + ) : ( + + onUpdateUserPassword(watch("id"), watch("password")) + } /> - - - {/* Show 'Set New Password for User' accordion if editing a user, show regular textfield if adding user */} - - {userAddMode ? ( - - ) : ( - - onUpdateUserPassword(watch("id"), watch("password")) - } - /> - )} - - - - {hasErrors() ? ( - - Please correct any errors before submission. - - ) : userAddMode ? ( - - ) : ( - )} + + {hasErrors() ? ( + + Please correct any errors before submission. + + ) : userAddMode ? ( + + ) : ( + + )} +
); diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index 9855882c..e3a38a6b 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -14,7 +14,6 @@ export const UserManagementView = () => { const [selectedRole, setSelectedRole] = useState(); const [roleAddMode, setRoleAddMode] = useState(true); - // Exit add mode when table row is selected useEffect(() => { if (selectedUser) setUserAddMode(false); }, [selectedUser]); diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index ac4f74f1..d770d81c 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -35,6 +35,7 @@ export const UsersTable = ({ { field: "full_name", headerName: "Full Name", width: 200 }, { field: "email", headerName: "Email", width: 250 }, { field: "username", headerName: "Username", width: 150 }, + { field: "display_name", headerName: "Display Name", width: 150 }, { field: "user_role", headerName: "Role", @@ -49,7 +50,6 @@ export const UsersTable = ({ }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid useEffect(() => { const psq = userSearchQuery.toLowerCase(); let filtered = (usersList.data ?? []).filter( From 8f503eaea83acb4d68eb40bcf016a75cefe34e8e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 15 Sep 2025 22:31:09 -0500 Subject: [PATCH 32/38] [views/UserManagement] Add redirect page to user table columns --- frontend/src/views/UserManagement/UsersTable.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index d770d81c..48f901ec 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -33,21 +33,23 @@ export const UsersTable = ({ const cols: GridColDef[] = [ { field: "full_name", headerName: "Full Name", width: 200 }, - { field: "email", headerName: "Email", width: 250 }, - { field: "username", headerName: "Username", width: 150 }, - { field: "display_name", headerName: "Display Name", width: 150 }, { field: "user_role", headerName: "Role", - width: 200, + width: 125, valueGetter: (_, row) => row.user_role.name, renderCell: (params: any) => }, + { field: "email", headerName: "Email", width: 250 }, + { field: "username", headerName: "Username", width: 150 }, { field: "disabled", headerName: "Active", + width: 80, renderCell: (params: any) => }, + { field: "display_name", headerName: "Display Name", width: 150 }, + { field: "redirect_page", headerName: "Redirect Page", width: 200 }, ]; useEffect(() => { From 861ab51e3cae3087f22974f9cc5c55a64ecbac55 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 15 Sep 2025 22:35:49 -0500 Subject: [PATCH 33/38] [routes/admin] Add display name to use creation --- api/routes/admin.py | 1 + api/schemas/security_schemas.py | 1 + 2 files changed, 2 insertions(+) diff --git a/api/routes/admin.py b/api/routes/admin.py index 432d4946..b4eb7d0e 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -86,6 +86,7 @@ def create_user(user: security_schemas.NewUser, db: Session = Depends(get_db)): username=user.username, email=user.email, full_name=user.full_name, + display_name=user.display_name, user_role_id=user.user_role_id, disabled=user.disabled, hashed_password=pwd_context.hash(user.password), diff --git a/api/schemas/security_schemas.py b/api/schemas/security_schemas.py index c12e30fa..27201f8d 100644 --- a/api/schemas/security_schemas.py +++ b/api/schemas/security_schemas.py @@ -30,6 +30,7 @@ class NewUser(ORMBase): username: str email: str full_name: str + display_name: str disabled: bool user_role_id: int password: str From 32176c64c63e920914d5b64fd31dac92b9755b39 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 16 Sep 2025 15:00:59 -0500 Subject: [PATCH 34/38] [ImageUploadWithPreview] Up the MAX_FILE_SIZE from 2 to 5 MB --- frontend/src/components/ImageUploadWithPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx index 8ecbd84d..e7d50b4f 100644 --- a/frontend/src/components/ImageUploadWithPreview.tsx +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -4,7 +4,7 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { ImageDialog, ImagePreviewGrid } from "./"; import { enqueueSnackbar } from "notistack"; -const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const VisuallyHiddenInput = (props: any) => ( Date: Tue, 16 Sep 2025 16:32:19 -0500 Subject: [PATCH 35/38] [routes/chlorides] Add null filtering, median and count calulations to endpoint --- api/routes/chlorides.py | 31 ++++++++----- api/templates/chlorides_report.html | 10 +++++ .../src/assets/leaflet/marker-icon-black.png | Bin 0 -> 1523 bytes frontend/src/components/DirectionCard.tsx | 8 +++- frontend/src/components/MapIcons/Black.tsx | 12 +++++ frontend/src/components/MapIcons/Blue.tsx | 12 +++++ frontend/src/components/MapIcons/Red.tsx | 12 +++++ frontend/src/components/MapIcons/index.ts | 3 ++ frontend/src/components/StatCell.tsx | 4 +- frontend/src/components/WellMapLegend.tsx | 40 +++++++---------- frontend/src/enums/WellStatus.ts | 7 +++ frontend/src/enums/index.ts | 1 + .../src/views/Reports/Chlorides/index.tsx | 38 ++++++++-------- .../views/WellManagement/WellSelectionMap.tsx | 41 ++++++------------ 14 files changed, 134 insertions(+), 85 deletions(-) create mode 100644 frontend/src/assets/leaflet/marker-icon-black.png create mode 100644 frontend/src/components/MapIcons/Black.tsx create mode 100644 frontend/src/components/MapIcons/Blue.tsx create mode 100644 frontend/src/components/MapIcons/Red.tsx create mode 100644 frontend/src/components/MapIcons/index.ts create mode 100644 frontend/src/enums/WellStatus.ts diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index 0f0f951f..455b298f 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -1,6 +1,7 @@ from typing import Optional, List from datetime import datetime import calendar +import statistics from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO @@ -94,17 +95,19 @@ def get_chloride_groups( for group_id, names in groups.items() ] -class MinMaxAvg(BaseModel): +class MinMaxAvgMedCount(BaseModel): min: Optional[float] = None max: Optional[float] = None avg: Optional[float] = None + median: Optional[float] = None + count: int = 0 class ChlorideReportNums(BaseModel): - north: MinMaxAvg - south: MinMaxAvg - east: MinMaxAvg - west: MinMaxAvg + north: MinMaxAvgMedCount + south: MinMaxAvgMedCount + east: MinMaxAvgMedCount + west: MinMaxAvgMedCount @authenticated_chlorides_router.get( @@ -336,13 +339,17 @@ def _month_end(dt: datetime) -> datetime: last_day = calendar.monthrange(dt.year, dt.month)[1] return dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=999999) -def _stats(values: List[float]) -> MinMaxAvg: - if not values: - return MinMaxAvg() - return MinMaxAvg( - min=min(values), - max=max(values), - avg=(sum(values) / len(values)) +def _stats(values: List[Optional[float]]) -> MinMaxAvgMedCount: + clean = [v for v in values if v is not None] + if not clean: + return MinMaxAvgMedCount() + + return MinMaxAvgMedCount( + min=min(clean), + max=max(clean), + avg=sum(clean) / len(clean), + median=statistics.median(clean), + count=len(clean), ) # Approx NM bounding box (degrees) diff --git a/api/templates/chlorides_report.html b/api/templates/chlorides_report.html index 1e15688e..0e77eebf 100644 --- a/api/templates/chlorides_report.html +++ b/api/templates/chlorides_report.html @@ -39,6 +39,8 @@

Chloride Report

Min Max Average + Median + Count @@ -47,24 +49,32 @@

Chloride Report

{{ report.north.min }} {{ report.north.max }} {{ "%.2f"|format(report.north.avg or 0) }} + {{ report.north.median }} + {{ report.north.count }} South {{ report.south.min }} {{ report.south.max }} {{ "%.2f"|format(report.south.avg or 0) }} + {{ report.south.median }} + {{ report.south.count }} East {{ report.east.min }} {{ report.east.max }} {{ "%.2f"|format(report.east.avg or 0) }} + {{ report.east.median }} + {{ report.east.count }} West {{ report.west.min }} {{ report.west.max }} {{ "%.2f"|format(report.west.avg or 0) }} + {{ report.west.median }} + {{ report.west.count }} diff --git a/frontend/src/assets/leaflet/marker-icon-black.png b/frontend/src/assets/leaflet/marker-icon-black.png new file mode 100644 index 0000000000000000000000000000000000000000..d262ae42eef90a127232ef9102438247d6e995d2 GIT binary patch literal 1523 zcmVP001cn1^@s6z>|W`00006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliru-3$s2Haq&T{civO1%OFJ zK~z}7rB~03T*VqaUsc`P>F((?Bylm$DiV-KHg6^gq$f-^F5J3tA-Z{sx6eX{@S?mI zi5M|19El+K6A{Ir5&wb4FwA1;Nr&ttY7&AXnwiO)%>7Zfs=m)n-=-(sGt(0bitby# zPM!MBcj{ge5sXvj=H?EF$P|Dl0IVvde!F(<+MnahrJX?L=H{LT@Gk%_iO3WYWlAZ} z%vwaMMDz!MFGb{&#l^*&djdIp_^_Iqnwlq~|HW~PD2gh^7$~JcL=X{(2)y@j&J}r{ z6M&0R6usVVxBadlr%s)Eiio~eO8ukJXw;NaU}i9Le{muL5kV=1vVU`PGYuj9%*@X( zEiL`HEy(Qb?6XAl-M)SMbgfp4LI?;U?0V)}YiO;JWtq>iEQrVpOG`^vhd^d$XAc4R zsnKXOqbP!N4&#af0Eh@t6v2CshYuenMD+CH;^MCWpa#Z$TB%g_MNx#JD7FP4qLJ+) zf}$v3j6tPRG0gmV*{1-Y)oQ&+M9#AU5y6884=_17iF4=9!5D)O0xn*>hnP(J$6XnD~03w3c8flv1$dMzsaNzJfy$);bP<)sf#+WLA(@I26mS;Fv z%J>{OaG;-bWj<)Fp_GC#28RwELI`0$Vnq2wLzML|3w7Gn2~ZVf3?waxpN2i@83tIQW**2oGV1+2R%7C*#PidwOWk_Wmq!N zTI25BySR1h7N)1C`-~PA7I5Xt6;!KLY?ohYnr6(rK*x?9dlJCCg9i_)QGON2F+vDP zk_65<)M_{Zg8y*(lH2sTgCBBuOeFZ+1GJ|8EP@OQcKAx!|0` zxIo63($~C*ygMZ6M;lgFRyF{0X0!R(PV4Ll((QJ=h@8#y zJkwhDKO4%|r997b0B1{&dMpqCSYKcNf|-A})}j=FF$UIJF!QhL>+4_bw#Kd?w{PEO z0Q1(`ynGev#Udx7`Ck9u0|9_$v-u4(-zkbh;y8x2Rz&2Nm6erm$C*DCNW0w@059ix zUNAGPwGO~5V}HBsYvQqy?*`rv2o|AJzg0vGc)al#kO{_Q=OikKD*aB Ze*rn_bmeHp9 { return ( @@ -21,8 +25,10 @@ export const DirectionCard = ({ - + + + diff --git a/frontend/src/components/MapIcons/Black.tsx b/frontend/src/components/MapIcons/Black.tsx new file mode 100644 index 00000000..2b0946ed --- /dev/null +++ b/frontend/src/components/MapIcons/Black.tsx @@ -0,0 +1,12 @@ +import L from "leaflet"; +import iconBlack from "./../../assets/leaflet/marker-icon-black.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +export const BlackMapIcon = L.icon({ + iconUrl: iconBlack, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/frontend/src/components/MapIcons/Blue.tsx b/frontend/src/components/MapIcons/Blue.tsx new file mode 100644 index 00000000..1afb6829 --- /dev/null +++ b/frontend/src/components/MapIcons/Blue.tsx @@ -0,0 +1,12 @@ +import L from "leaflet"; +import iconBlue from "leaflet/dist/images/marker-icon.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +export const BlueMapIcon = L.icon({ + iconUrl: iconBlue, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/frontend/src/components/MapIcons/Red.tsx b/frontend/src/components/MapIcons/Red.tsx new file mode 100644 index 00000000..5e8c9352 --- /dev/null +++ b/frontend/src/components/MapIcons/Red.tsx @@ -0,0 +1,12 @@ +import L from "leaflet"; +import iconRed from "./../../assets/leaflet/marker-icon-red.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +export const RedMapIcon = L.icon({ + iconUrl: iconRed, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/frontend/src/components/MapIcons/index.ts b/frontend/src/components/MapIcons/index.ts new file mode 100644 index 00000000..0156b7a1 --- /dev/null +++ b/frontend/src/components/MapIcons/index.ts @@ -0,0 +1,3 @@ +export * from "./Black" +export * from "./Blue" +export * from "./Red" diff --git a/frontend/src/components/StatCell.tsx b/frontend/src/components/StatCell.tsx index 3fd8f43a..954ac962 100644 --- a/frontend/src/components/StatCell.tsx +++ b/frontend/src/components/StatCell.tsx @@ -1,13 +1,13 @@ import { Stack, Typography } from "@mui/material"; import { formatNumberData } from "../utils"; -export const StatCell = ({ label, value }: { label: string; value?: number }) => { +export const StatCell = ({ label, value, isCount }: { label: string; value?: number, isCount?: boolean }) => { return ( {label} - {formatNumberData(value)} ppm + {formatNumberData(value)}{isCount ? "" : " ppm"} ); } diff --git a/frontend/src/components/WellMapLegend.tsx b/frontend/src/components/WellMapLegend.tsx index d6a5e8d3..ba5bf5c6 100644 --- a/frontend/src/components/WellMapLegend.tsx +++ b/frontend/src/components/WellMapLegend.tsx @@ -1,26 +1,10 @@ import React from "react"; -import L from "leaflet"; -import iconBlue from "leaflet/dist/images/marker-icon.png"; -import iconRed from "../assets/leaflet/marker-icon-red.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; +import { + BlackMapIcon, + BlueMapIcon, + RedMapIcon, +} from './MapIcons'; -const blueIcon = L.icon({ - iconUrl: iconBlue, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - -const redIcon = L.icon({ - iconUrl: iconRed, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); export const WellMapLegend: React.FC = () => { return ( @@ -39,20 +23,28 @@ export const WellMapLegend: React.FC = () => { >
Well Well
-
+
Chloride Monitored Well Chloride Monitored Well
+
+ Chloride Monitored Well + Plugged Well +
); }; diff --git a/frontend/src/enums/WellStatus.ts b/frontend/src/enums/WellStatus.ts new file mode 100644 index 00000000..b48b5b21 --- /dev/null +++ b/frontend/src/enums/WellStatus.ts @@ -0,0 +1,7 @@ +export enum WellStatus { + ACTIVE = 1, + INACTIVE = 2, + COLLAPSED = 3, + UNKNOWN = 4, + PLUGGED = 5, +} diff --git a/frontend/src/enums/index.ts b/frontend/src/enums/index.ts index 78afddef..7e3304c0 100644 --- a/frontend/src/enums/index.ts +++ b/frontend/src/enums/index.ts @@ -5,6 +5,7 @@ export * from "./MeterHistoryType"; export * from "./MeterSortByField"; export * from "./MeterStatusNames"; export * from "./SortDirection"; +export * from "./WellStatus"; export * from "./WellSortByField"; export * from "./WorkingOnArrivalValue"; export * from "./WorkOrderStatus"; diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 156bc0ea..df061442 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -29,24 +29,14 @@ import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer, Sa import { useFetchWithAuth } from "../../../hooks"; import { useGetWellLocations } from "../../../service/ApiServiceNew"; import { Well } from "../../../interfaces"; - -import iconRed from "../../../assets/leaflet/marker-icon-red.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; +import { RedMapIcon, BlackMapIcon } from "../../../components/MapIcons"; +import { WellStatus } from "../../../enums"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "leaflet/dist/leaflet.css"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; -const redIcon = L.icon({ - iconUrl: iconRed, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), to: yup @@ -64,17 +54,19 @@ const defaultSchema = { to: dayjs(), }; -interface iMinMaxAvg { +interface iMinMaxAvgMedCount { min?: number; max?: number; avg?: number; + median?: number; + count?: number; } interface iChlorideReportNums { - north: iMinMaxAvg; - south: iMinMaxAvg; - east: iMinMaxAvg; - west: iMinMaxAvg; + north: iMinMaxAvgMedCount; + south: iMinMaxAvgMedCount; + east: iMinMaxAvgMedCount; + west: iMinMaxAvgMedCount; } export const ChloridesReportView = () => { @@ -266,6 +258,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.north?.min} avg={chloridesQuery.data?.north?.avg} max={chloridesQuery.data?.north?.max} + median={chloridesQuery.data?.north?.median} + count={chloridesQuery.data?.north?.count} /> @@ -274,6 +268,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.south?.min} avg={chloridesQuery.data?.south?.avg} max={chloridesQuery.data?.south?.max} + median={chloridesQuery.data?.south?.median} + count={chloridesQuery.data?.south?.count} /> @@ -282,6 +278,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.east?.min} avg={chloridesQuery.data?.east?.avg} max={chloridesQuery.data?.east?.max} + median={chloridesQuery.data?.east?.median} + count={chloridesQuery.data?.east?.count} /> @@ -290,6 +288,8 @@ export const ChloridesReportView = () => { min={chloridesQuery.data?.west?.min} avg={chloridesQuery.data?.west?.avg} max={chloridesQuery.data?.west?.max} + median={chloridesQuery.data?.west?.median} + count={chloridesQuery.data?.west?.count} /> @@ -349,7 +349,9 @@ export const ChloridesReportView = () => { well.location?.latitude, well.location?.longitude, ]} - icon={redIcon} + icon={ + well.well_status_id === WellStatus.PLUGGED ? BlackMapIcon : RedMapIcon + } > {well.name || well.ra_number || well.id} diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index 89992253..efeae789 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -1,36 +1,15 @@ import { useEffect } from "react"; import { useDebounce } from "use-debounce"; - import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; - -import L from "leaflet"; -import iconBlue from "leaflet/dist/images/marker-icon.png"; -import iconRed from "../../assets/leaflet/marker-icon-red.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; - -const blueIcon = L.icon({ - iconUrl: iconBlue, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - -const redIcon = L.icon({ - iconUrl: iconRed, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41], -}); - -import "leaflet/dist/leaflet.css"; +import { Box, Typography } from "@mui/material"; import { useGetWellLocations } from "../../service/ApiServiceNew"; import { Well } from "../../interfaces"; -import { Box, Typography } from "@mui/material"; import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, WellMapLegend } from "../../components"; +import { BlueMapIcon, RedMapIcon, BlackMapIcon } from "../../components/MapIcons"; +import { WellStatus } from "../../enums"; + +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; @@ -114,7 +93,13 @@ export default function WellSelectionMap({ eventHandlers={{ click: () => setSelectedWell(well), }} - icon={well.chloride_group_id != null ? redIcon : blueIcon} + icon={ + well.well_status_id === WellStatus.PLUGGED + ? BlueMapIcon + : well.chloride_group_id != null + ? RedMapIcon + : BlackMapIcon + } > {well.name || well.ra_number || well.id} From 15949c6595d110c9cd2c9606f6277ffc45b4e8da Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 16 Sep 2025 16:46:05 -0500 Subject: [PATCH 36/38] [/migrations] Update WellMeasurements column value to be NULLABLE --- ..._WellMeasurements_column_value_to_be_NULLABLE.down.sql | 8 ++++++++ ...te_WellMeasurements_column_value_to_be_NULLABLE.up.sql | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql create mode 100644 migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql diff --git a/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql new file mode 100644 index 00000000..f380a688 --- /dev/null +++ b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.down.sql @@ -0,0 +1,8 @@ +-- 1. Replace all NULL values with zero +UPDATE public."WellMeasurements" +SET value = 0 +WHERE value IS NULL; + +-- 2. Reinstate NOT NULL constraint +ALTER TABLE public."WellMeasurements" +ALTER COLUMN value SET NOT NULL; diff --git a/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql new file mode 100644 index 00000000..dbd638b2 --- /dev/null +++ b/migrations/20250916155713_update_WellMeasurements_column_value_to_be_NULLABLE.up.sql @@ -0,0 +1,8 @@ +-- 1. Drop NOT NULL constraint +ALTER TABLE public."WellMeasurements" +ALTER COLUMN value DROP NOT NULL; + +-- 2. Replace all zero values with NULL +UPDATE public."WellMeasurements" +SET value = NULL +WHERE value = 0; From 652349d1758934397a112a2f3f1a42c6a3b10127 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 16 Sep 2025 17:16:23 -0500 Subject: [PATCH 37/38] [enums/WellStatus] Fix PLUGGED number --- frontend/src/enums/WellStatus.ts | 4 ++-- .../views/WellManagement/WellSelectionMap.tsx | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/enums/WellStatus.ts b/frontend/src/enums/WellStatus.ts index b48b5b21..f0a15510 100644 --- a/frontend/src/enums/WellStatus.ts +++ b/frontend/src/enums/WellStatus.ts @@ -2,6 +2,6 @@ export enum WellStatus { ACTIVE = 1, INACTIVE = 2, COLLAPSED = 3, - UNKNOWN = 4, - PLUGGED = 5, + PLUGGED = 4, + UNKNOWN = 5, } diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index efeae789..c622f495 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -93,13 +93,7 @@ export default function WellSelectionMap({ eventHandlers={{ click: () => setSelectedWell(well), }} - icon={ - well.well_status_id === WellStatus.PLUGGED - ? BlueMapIcon - : well.chloride_group_id != null - ? RedMapIcon - : BlackMapIcon - } + icon={getWellIcon(well)} > {well.name || well.ra_number || well.id} @@ -154,3 +148,14 @@ export default function WellSelectionMap({ ); } + +const getWellIcon = (well: Well) => { + if (well.well_status_id === WellStatus.PLUGGED) { + return BlackMapIcon; + } + if (well.chloride_group_id != null) { + return RedMapIcon; + } + return BlueMapIcon; +} + From a45594c7ba1373f90b26deeb6e036b172c1a865c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 17 Sep 2025 10:02:45 -0500 Subject: [PATCH 38/38] [routes/activities] Add backend support for photos limits --- api/routes/activities.py | 34 ++++++- frontend/src/components/ImagePreviewGrid.tsx | 92 +++++++++---------- .../src/components/ImageUploadWithPreview.tsx | 91 ++++++++++++------ .../MeterActivityEntry/NotesSelection.tsx | 2 +- 4 files changed, 141 insertions(+), 78 deletions(-) diff --git a/api/routes/activities.py b/api/routes/activities.py index 9cb146f5..629a3867 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -40,6 +40,9 @@ BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") PHOTO_PREFIX = os.getenv("GCP_PHOTO_PREFIX", "") +MAX_PHOTOS_PER_REQUEST = 2 +MAX_PHOTOS_PER_METER = 6 + @activity_router.post( "/activities", response_model=meter_schemas.MeterActivity, @@ -48,7 +51,7 @@ ) async def post_activity( activity: str = Form(...), # JSON string from FormData - photos: list[UploadFile] = File(None), # optional uploaded images + photos: list[UploadFile] = File(None), db: Session = Depends(get_db), user: Users = Depends(get_current_user), ): @@ -56,6 +59,14 @@ async def post_activity( Handles submission of an activity (with optional file uploads). """ + if photos: + if len(photos) > MAX_PHOTOS_PER_REQUEST: + raise HTTPException( + status_code=400, + detail=f"Too many photos uploaded. " + f"Max {MAX_PHOTOS_PER_REQUEST} allowed per request, got {len(photos)}.", + ) + try: activity_form = meter_schemas.ActivityForm.parse_obj(json.loads(activity)) except Exception as e: @@ -286,6 +297,27 @@ async def post_activity( print(f"Saved {len(photos)} photos for activity {meter_activity.id}") db.refresh(meter_activity) + # ---- Enforce per-meter retention ---- + all_photos = ( + db.query(MeterActivityPhotos) + .join(MeterActivities) + .filter(MeterActivities.meter_id == meter_activity.meter_id) + .order_by(MeterActivityPhotos.uploaded_at.desc()) + .all() + ) + + if len(all_photos) > MAX_PHOTOS_PER_METER: + # keep newest MAX_PHOTOS_PER_METER, delete the rest + to_delete = all_photos[MAX_PHOTOS_PER_METER:] + for old_photo in to_delete: + try: + bucket.blob(old_photo.gcs_path).delete() + except Exception as e: + print(f"Warning: failed to delete {old_photo.gcs_path} from GCS: {e}") + db.delete(old_photo) + + db.commit() + return meter_activity @activity_router.patch( diff --git a/frontend/src/components/ImagePreviewGrid.tsx b/frontend/src/components/ImagePreviewGrid.tsx index 05546628..9f28fd56 100644 --- a/frontend/src/components/ImagePreviewGrid.tsx +++ b/frontend/src/components/ImagePreviewGrid.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Box, Typography, IconButton } from "@mui/material"; +import { Box, IconButton } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; export const ImagePreviewGrid = memo(({ previews, onRemove, onOpen }: { @@ -8,59 +8,53 @@ export const ImagePreviewGrid = memo(({ previews, onRemove, onOpen }: { onOpen?: (src: string) => void; }) => { return ( - - - Preview{previews.length >= 2 ? "s" : null}: - - - {previews.map((src, i) => { - return ( + + {previews.map((src, i) => { + return ( + onOpen?.(src)} + > onOpen?.(src)} - > - + {onRemove && ( + onRemove(i)} sx={{ - width: "100%", - height: "100%", - objectFit: "cover", + position: "absolute", + top: 0, + right: 0, + backgroundColor: "rgba(255,255,255,0.7)", + border: "1px solid black", + "&:hover": { + backgroundColor: "rgba(255,0,0,0.8)", + color: "white", + }, }} - /> - {onRemove && ( - onRemove(i)} - sx={{ - position: "absolute", - top: 0, - right: 0, - backgroundColor: "rgba(255,255,255,0.7)", - border: "1px solid black", - "&:hover": { - backgroundColor: "rgba(255,0,0,0.8)", - color: "white", - }, - }} - > - - - )} - - ); - })} - + > + + + )} + + ); + })} ); }); - diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx index e7d50b4f..98abf5de 100644 --- a/frontend/src/components/ImageUploadWithPreview.tsx +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Grid, Button } from "@mui/material"; +import { Grid, Button, Typography, Box } from "@mui/material"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { ImageDialog, ImagePreviewGrid } from "./"; import { enqueueSnackbar } from "notistack"; @@ -13,40 +13,69 @@ const VisuallyHiddenInput = (props: any) => ( /> ); -export const ImageUploadWithPreview = ({ onFilesChange }: { onFilesChange?: (files: File[]) => void }) => { +export const ImageUploadWithPreview = ({ + onFilesChange, + fileLimit, +}: { + onFilesChange?: (files: File[]) => void; + fileLimit?: number; +}) => { const [previews, setPreviews] = useState([]); const [dialogOpen, setDialogOpen] = useState(false); const [selectedImage, setSelectedImage] = useState(null); - const [, setFiles] = useState([]); + const [files, setFiles] = useState([]); const handleFileChange = (event: React.ChangeEvent) => { const files = event.target.files; if (!files) return; - const imageFiles = Array.from(files).filter((file) => + let imageFiles = Array.from(files).filter((file) => file.type.startsWith("image/") ); + // enforce max file size const tooBig = imageFiles.filter((f) => f.size > MAX_FILE_SIZE); if (tooBig.length > 0) { enqueueSnackbar( - `Some files are too large. Max allowed size is ${MAX_FILE_SIZE / 1024 / 1024 - } MB`, + `Some files are too large. Max allowed size is ${MAX_FILE_SIZE / 1024 / 1024} MB.`, { variant: "error" } ); - event.target.value = ""; - return; + imageFiles = imageFiles.filter((f) => f.size <= MAX_FILE_SIZE); } + // enforce file limit setFiles((prev) => { - const updated = [...prev, ...imageFiles]; - onFilesChange?.(updated); // bubble up to parent form + let updated = [...prev]; + + if (fileLimit) { + const remaining = fileLimit - updated.length; + + if (remaining <= 0) { + enqueueSnackbar(`You can only upload up to ${fileLimit} images.`, { + variant: "warning", + }); + event.target.value = ""; + return updated; // no changes + } + + if (imageFiles.length > remaining) { + enqueueSnackbar(`Only ${remaining} more image${remaining > 1 ? "s" : ""} allowed.`, { + variant: "info", + }); + imageFiles = imageFiles.slice(0, remaining); + } + } + + updated = [...updated, ...imageFiles]; + onFilesChange?.(updated); + + // set previews too + const urls = imageFiles.map((file) => URL.createObjectURL(file)); + setPreviews((prevPreviews) => [...prevPreviews, ...urls]); + return updated; }); - const urls = imageFiles.map((file) => URL.createObjectURL(file)); - setPreviews((prev) => [...prev, ...urls]); // append instead of replace - event.target.value = ""; }; @@ -67,20 +96,28 @@ export const ImageUploadWithPreview = ({ onFilesChange }: { onFilesChange?: (fil }; return ( - - + + + + {fileLimit && ( + + {files.length}/{fileLimit} images uploaded + + )} + {previews.length > 0 && ( <> ( - field.onChange(files)} /> + field.onChange(files)} /> )} />