From b7bff719b0eff73e02c82230bfbdb65d4f93468a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 15 Jan 2026 12:36:57 -0600 Subject: [PATCH 01/91] [MeterRegisterSelect] Update component to allow for any register if meter_type is null --- .../src/components/MeterRegisterSelect.tsx | 53 +++++++++++++------ .../src/views/Meters/MeterDetailsFields.tsx | 14 ++--- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/MeterRegisterSelect.tsx b/frontend/src/components/MeterRegisterSelect.tsx index 08e56657..3c1255d6 100644 --- a/frontend/src/components/MeterRegisterSelect.tsx +++ b/frontend/src/components/MeterRegisterSelect.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo } from "react"; import { useGetMeterRegisterList } from "../service/ApiServiceNew"; import { FormControl, @@ -35,23 +35,17 @@ export default function MeterRegisterSelect({ helperText?: string; }) { const meterRegisterList = useGetMeterRegisterList(); - const [filteredRegisterList, setFilteredRegisterList] = useState< - MeterRegister[] | undefined - >([]); //Filter the register list based on the meter type - useEffect(() => { - if (meterType) { - setFilteredRegisterList( - meterRegisterList.data?.filter( - (register: MeterRegister) => - register.meter_size == meterType.size && - register.brand.toLowerCase() == meterType.brand?.toLowerCase(), - ), - ); - } else { - setFilteredRegisterList(meterRegisterList.data); - } + const filteredRegisterList = useMemo(() => { + if (!meterType || meterTypeIsUnknown(meterType)) + return meterRegisterList.data ?? []; + + return (meterRegisterList.data ?? []).filter( + (r: MeterRegister) => + r.meter_size == meterType.size && + r.brand.toLowerCase() == meterType.brand?.toLowerCase(), + ); }, [meterType, meterRegisterList.data]); //Check if the selected register is in the filtered list, if not, set it to null @@ -103,3 +97,30 @@ export default function MeterRegisterSelect({ ); } + +const meterTypeLabel = ( + meterType?: MeterType | string | null, +): string | null => { + if (!meterType) return null; + + // If some code path passes a raw string + if (typeof meterType === "string") { + const s = meterType.trim(); + return s.length ? s : null; + } + + // Prefer description (often includes "Unknown") + const desc = meterType.description?.trim(); + if (desc) return desc; + + // Fall back to the same style you show in the select + const brand = meterType.brand?.trim() ?? ""; + const model = meterType.model?.trim() ?? ""; + const series = meterType.series?.trim() ?? ""; + + const label = [brand, series, model].filter(Boolean).join(" - ").trim(); + return label.length ? label : null; +}; + +const meterTypeIsUnknown = (meterType?: MeterType | string | null): boolean => + (meterTypeLabel(meterType) ?? "").toLowerCase().includes("unknown"); diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 231b19a8..f9aba705 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -34,9 +34,7 @@ import { CustomCardHeader } from "../../components/CustomCardHeader"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), - price: Yup.number() - .nullable() - .min(0, "Price cannot be negative"), + price: Yup.number().nullable().min(0, "Price cannot be negative"), meter_type: Yup.object().required("Please select a meter type."), meter_register: Yup.object().required("Please select a meter register."), }); @@ -184,7 +182,9 @@ export const MeterDetailsFields = ({ type="number" inputProps={{ step: "0.01" }} InputProps={{ - startAdornment: $, + startAdornment: ( + $ + ), }} /> @@ -221,9 +221,9 @@ export const MeterDetailsFields = ({ {watch("well")?.location?.latitude == null ? "--" : formatLatLong( - watch("well")?.location?.latitude, - watch("well")?.location?.longitude, - )} + watch("well")?.location?.latitude, + watch("well")?.location?.longitude, + )} {watch("well")?.osetag == null From 13f2b5062b3622fd188ca26c2330d80b9291c6a1 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 27 Jan 2026 17:12:37 -0600 Subject: [PATCH 02/91] [src/views/**Table] Update imports on table views & add increase quantity btn on parts pg --- .../src/components/GridFooterWithButton.tsx | 8 +- frontend/src/components/TristateToggle.tsx | 4 +- frontend/src/components/index.ts | 61 +++++++------- .../MeterSelection/MeterSelectionTable.tsx | 22 ++--- frontend/src/views/Parts/MeterTypesTable.tsx | 62 +++++++++----- frontend/src/views/Parts/PartsTable.tsx | 84 ++++++++++++++----- .../views/UserManagement/PermissionsTable.tsx | 46 ++++++---- .../src/views/UserManagement/RolesTable.tsx | 30 +++---- .../src/views/UserManagement/UsersTable.tsx | 57 ++++++++----- .../WellManagement/WellSelectionTable.tsx | 32 ++++--- .../src/views/WorkOrders/WorkOrdersTable.tsx | 33 ++++---- frontend/tsconfig.app.json | 6 ++ frontend/tsconfig.json | 6 ++ frontend/vite.config.ts | 6 ++ 14 files changed, 288 insertions(+), 169 deletions(-) diff --git a/frontend/src/components/GridFooterWithButton.tsx b/frontend/src/components/GridFooterWithButton.tsx index 676bf7ab..1977a5f2 100644 --- a/frontend/src/components/GridFooterWithButton.tsx +++ b/frontend/src/components/GridFooterWithButton.tsx @@ -2,15 +2,11 @@ import { Box } from "@mui/material"; import { GridPagination } from "@mui/x-data-grid"; import { ReactNode } from "react"; -export default function GridFooterWithButton({ - button, -}: { - button: ReactNode; -}) { +export const GridFooterWithButton = ({ button }: { button: ReactNode }) => { return ( {button} ); -} +}; diff --git a/frontend/src/components/TristateToggle.tsx b/frontend/src/components/TristateToggle.tsx index c0cef2e1..d3032cae 100644 --- a/frontend/src/components/TristateToggle.tsx +++ b/frontend/src/components/TristateToggle.tsx @@ -1,7 +1,7 @@ import { Chip } from "@mui/material"; import { useEffect, useState } from "react"; -export default function TristateToggle({ label, onToggle }: any) { +export const TristateToggle = ({ label, onToggle }: any) => { const [toggleState, setToggleState] = useState(); useEffect(() => { @@ -41,4 +41,4 @@ export default function TristateToggle({ label, onToggle }: any) { onClick={() => setToggleState(!toggleState)} /> ); -} +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 10191177..8e6f9dab 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -1,30 +1,31 @@ -export * from './BackgroundBox' -export * from './ModalBackgroundBox' -export * from './TristateToggle' -export * from './TopbarUserButton' -export * from './ChipSelect' -export * from './MergeWellModal' -export * from './UserSelection' -export * from './CustomCardHeader' -export * from './MeterRegisterSelect' -export * from './RHControlled' -export * from './ReportsNavItem' -export * from './RoleChip' -export * from './IsTrueChip' -export * from './DirectionCard' -export * from './MeterSelection' -export * from './StatCell' -export * from './StyledToggleButton' -export * from './WellSelection' -export * from './MeterTypeSelect' -export * from './TabPanel' -export * from './Layers' -export * from './WorkOrderSelect' -export * from './GridFooterWithButton' -export * from './NavLink' -export * from './Topbar' -export * from './WellMapLegend' -export * from './ImagePreviewGrid' -export * from './ImageUploadWithPreview' -export * from './ImageDialog' -export * from './Modals' +export * from "./BackgroundBox"; +export * from "./ModalBackgroundBox"; +export * from "./TristateToggle"; +export * from "./TopbarUserButton"; +export * from "./ChipSelect"; +export * from "./MergeWellModal"; +export * from "./UserSelection"; +export * from "./CustomCardHeader"; +export * from "./MeterRegisterSelect"; +export * from "./RHControlled"; +export * from "./ReportsNavItem"; +export * from "./RoleChip"; +export * from "./IsTrueChip"; +export * from "./DirectionCard"; +export * from "./MeterSelection"; +export * from "./StatCell"; +export * from "./StyledToggleButton"; +export * from "./WellSelection"; +export * from "./MeterTypeSelect"; +export * from "./TabPanel"; +export * from "./Layers"; +export * from "./WorkOrderSelect"; +export * from "./GridFooterWithButton"; +export * from "./NavLink"; +export * from "./Topbar"; +export * from "./WellMapLegend"; +export * from "./ImagePreviewGrid"; +export * from "./ImageUploadWithPreview"; +export * from "./ImageDialog"; +export * from "./Modals"; +export * from "./GridFooterWithButton"; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 39deb5f2..2b5b6b5e 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -2,16 +2,12 @@ import { useState, useEffect } from "react"; import { useDebounce } from "use-debounce"; import { Box, Button, Stack } from "@mui/material"; import { DataGrid, GridSortModel, GridColDef } from "@mui/x-data-grid"; -import AddIcon from "@mui/icons-material/Add"; -import { MeterListQueryParams, SecurityScope } from "../../../interfaces"; -import { - SortDirection, - MeterSortByField, - MeterStatusNames, -} from "../../../enums"; -import { useGetMeterList } from "../../../service/ApiServiceNew"; -import GridFooterWithButton from "../../../components/GridFooterWithButton"; +import { Add } from "@mui/icons-material"; import { useAuthUser } from "react-auth-kit"; +import { MeterListQueryParams, SecurityScope } from "@/interfaces"; +import { SortDirection, MeterSortByField, MeterStatusNames } from "@/enums"; +import { useGetMeterList } from "@/service/ApiServiceNew"; +import { GridFooterWithButton } from "@/components"; interface MeterSelectionTableProps { onMeterSelection: Function; @@ -131,7 +127,11 @@ export const MeterSelectionTable = ({ diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index 9ece1568..13c01d6c 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -10,14 +10,15 @@ import { TextField, Typography, } from "@mui/material"; -import { Search } from "@mui/icons-material"; -import { useGetMeterTypeList } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { MeterTypeLU } from "../../interfaces"; -import TristateToggle from "../../components/TristateToggle"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { IsTrueChip, CustomCardHeader } from "../../components"; +import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { useGetMeterTypeList } from "@/service/ApiServiceNew"; +import { MeterTypeLU } from "@/interfaces"; +import { + CustomCardHeader, + GridFooterWithButton, + IsTrueChip, + TristateToggle, +} from "@/components"; export const MeterTypesTable = ({ setSelectedMeterType, @@ -44,7 +45,7 @@ export const MeterTypesTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => + renderCell: (params: any) => , }, ]; @@ -69,18 +70,28 @@ export const MeterTypesTable = ({ - + setMeterTypeSearchQuery(event.target.value)} + onChange={(event: any) => + setMeterTypeSearchQuery(event.target.value) + } InputProps={{ startAdornment: ( @@ -90,8 +101,18 @@ export const MeterTypesTable = ({ }} /> - - Choose Filters: + + + Choose Filters:{" "} + setInUseFilter(state)} @@ -115,17 +136,20 @@ export const MeterTypesTable = ({ @@ -136,6 +160,6 @@ export const MeterTypesTable = ({ /> - + ); }; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 8d4f74bc..02c65c9f 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -10,15 +10,20 @@ import { TextField, Typography, } from "@mui/material"; -import { Search } from "@mui/icons-material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { useGetParts } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import { Part } from "../../interfaces"; -import TristateToggle from "../../components/TristateToggle"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; -import { IsTrueChip } from "../../components"; +import { + PlusOne, + Search, + Add, + FormatListBulletedOutlined, +} from "@mui/icons-material"; +import { useGetParts } from "@/service/ApiServiceNew"; +import { Part } from "@/interfaces"; +import { + CustomCardHeader, + GridFooterWithButton, + IsTrueChip, + TristateToggle, +} from "@/components"; export const PartsTable = ({ setSelectedPartID, @@ -46,12 +51,12 @@ export const PartsTable = ({ { field: "in_use", headerName: "In Use", - renderCell: (params: any) => + renderCell: (params: any) => , }, { field: "commonly_used", headerName: "Commonly Used", - renderCell: (params: any) => + renderCell: (params: any) => , }, ]; @@ -76,15 +81,20 @@ export const PartsTable = ({ return ( - + - + - - Choose Filters: + + + Choose Filters:{" "} + setInUseFilter(state)} @@ -129,18 +149,38 @@ export const PartsTable = ({ + ), }, diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 0f6a66a8..03c25c36 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -1,13 +1,18 @@ import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Button, Card, CardContent, Grid, InputAdornment, TextField, Tooltip } from "@mui/material"; -import { useGetSecurityScopes } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import { Search } from "@mui/icons-material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { SecurityScope } from "../../interfaces"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { + Button, + Card, + CardContent, + Grid, + InputAdornment, + TextField, + Tooltip, +} from "@mui/material"; +import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { useGetSecurityScopes } from "@/service/ApiServiceNew"; +import { SecurityScope } from "@/interfaces"; +import { CustomCardHeader, GridFooterWithButton } from "@/components"; export const PermissionsTable = () => { const securityScopesList = useGetSecurityScopes(); @@ -36,17 +41,27 @@ export const PermissionsTable = () => { - + setPermissionSearchQuery(event.target.value)} + onChange={(event: any) => + setPermissionSearchQuery(event.target.value) + } InputProps={{ startAdornment: ( @@ -73,9 +88,12 @@ export const PermissionsTable = () => { disabled variant="contained" size="small" - sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" } }} + sx={{ + flexShrink: 0, + width: { xs: "100%", sm: "auto" }, + }} > - + Create diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 6ccf9994..fa3d6ca1 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -9,13 +9,10 @@ import { InputAdornment, TextField, } from "@mui/material"; -import { Search } from "@mui/icons-material"; -import { useGetRoles } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { UserRole } from "../../interfaces"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { useGetRoles } from "@/service/ApiServiceNew"; +import { UserRole } from "@/interfaces"; +import { CustomCardHeader, GridFooterWithButton } from "@/components"; export const RolesTable = ({ setSelectedRole, @@ -67,15 +64,20 @@ export const RolesTable = ({ return ( - + - + setRoleAddMode(true)} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" } }} > - + Create ), diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 48f901ec..34a2bcc1 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -9,14 +9,16 @@ import { TextField, Typography, } from "@mui/material"; -import { Search } from "@mui/icons-material"; -import { useGetUserAdminList } from "../../service/ApiServiceNew"; -import AddIcon from "@mui/icons-material/Add"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { User } from "../../interfaces"; -import TristateToggle from "../../components/TristateToggle"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { RoleChip, CustomCardHeader, IsTrueChip } from "../../components"; +import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { useGetUserAdminList } from "@/service/ApiServiceNew"; +import { User } from "@/interfaces"; +import { + CustomCardHeader, + GridFooterWithButton, + IsTrueChip, + RoleChip, + TristateToggle, +} from "@/components"; export const UsersTable = ({ setSelectedUser, @@ -38,7 +40,7 @@ export const UsersTable = ({ headerName: "Role", width: 125, valueGetter: (_, row) => row.user_role.name, - renderCell: (params: any) => + renderCell: (params: any) => , }, { field: "email", headerName: "Email", width: 250 }, { field: "username", headerName: "Username", width: 150 }, @@ -46,7 +48,7 @@ export const UsersTable = ({ field: "disabled", headerName: "Active", width: 80, - renderCell: (params: any) => + renderCell: (params: any) => , }, { field: "display_name", headerName: "Display Name", width: 150 }, { field: "redirect_page", headerName: "Redirect Page", width: 200 }, @@ -72,15 +74,20 @@ export const UsersTable = ({ return ( - + - + - - Choose Filters: + + + Choose Filters:{" "} + @@ -135,7 +152,7 @@ export const UsersTable = ({ onClick={() => setUserAddMode(true)} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" } }} > - + Create ), @@ -145,6 +162,6 @@ export const UsersTable = ({ /> - + ); }; diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index b91cc863..a4311fd0 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -1,20 +1,19 @@ -import { Box, Button, Stack } from "@mui/material"; +import { useEffect, useState, ReactNode } from "react"; import { Link } from "react-router-dom"; import { DataGrid, GridColDef, GridSortModel } from "@mui/x-data-grid"; -import React, { useEffect, useState } from "react"; import { useDebounce } from "use-debounce"; -import { SecurityScope } from "../../interfaces"; -import { useGetWells } from "../../service/ApiServiceNew"; import { useAuthUser } from "react-auth-kit"; -import { SortDirection, WellSortByField } from "../../enums"; -import { Well, WellListQueryParams } from "../../interfaces"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import AddIcon from "@mui/icons-material/Add"; +import { Box, Button, Stack } from "@mui/material"; +import { Add } from "@mui/icons-material"; +import { SecurityScope, Well, WellListQueryParams } from "@/interfaces"; +import { useGetWells } from "@/service/ApiServiceNew"; +import { SortDirection, WellSortByField } from "@/enums"; +import { GridFooterWithButton } from "@/components"; //This is needed for typescript to recognize the slotProps... see https://v6.mui.com/x/react-data-grid/components/#custom-slot-props-with-typescript declare module "@mui/x-data-grid" { interface FooterPropsOverrides { - button: React.ReactNode; + button: ReactNode; } } @@ -92,7 +91,12 @@ export default function WellSelectionTable({ const meters = params.value as Well["meters"]; const links = meters.map((meter, index) => ( - + {meter.serial_number} {index < params.value.length - 1 ? ", " : ""} @@ -152,7 +156,11 @@ export default function WellSelectionTable({ diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 56cf9be5..19063c5e 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -10,28 +10,19 @@ import { GridRowId, GridFilterItem, } from "@mui/x-data-grid"; +import { useAuthUser } from "react-auth-kit"; +import { Link, createSearchParams } from "react-router-dom"; import { useGetWorkOrders, useUpdateWorkOrder, useGetUserList, useDeleteWorkOrder, useCreateWorkOrder, -} from "../../service/ApiServiceNew"; -import { WorkOrderStatus } from "../../enums"; -import { - Box, - Button, - IconButton, - Stack, -} from "@mui/material"; -import GridFooterWithButton from "../../components/GridFooterWithButton"; -import { - MeterActivity, - NewWorkOrder, - SecurityScope, -} from "../../interfaces"; -import { useAuthUser } from "react-auth-kit"; -import { Link, createSearchParams } from "react-router-dom"; +} from "@/service/ApiServiceNew"; +import { WorkOrderStatus } from "@/enums"; +import { Box, Button, IconButton, Stack } from "@mui/material"; +import { GridFooterWithButton } from "@/components"; +import { MeterActivity, NewWorkOrder, SecurityScope } from "@/interfaces"; import { DeleteWorkOrder } from "./DeleteWorkOrder"; import { NewWorkOrderModal } from "./NewWorkOrderModal"; @@ -148,7 +139,7 @@ export default function WorkOrdersTable() { field: "work_order_id", headerName: "ID", flex: 1, - minWidth: 50 + minWidth: 50, }, { field: "date_created", @@ -314,7 +305,7 @@ export default function WorkOrdersTable() { ]; return ( - + "auto"} @@ -341,7 +332,11 @@ export default function WorkOrdersTable() { - - - - + Enter the measurement details below. Date and time default to the + current moment and can be adjusted if needed. + + + + + + + + + + setValue(event.target.value as unknown as number) + } + /> + + + + + + + + ); -} +}; diff --git a/frontend/src/components/Modals/MonitoredWell/Update.tsx b/frontend/src/components/Modals/MonitoredWell/Update.tsx index 4808043b..93b423b6 100644 --- a/frontend/src/components/Modals/MonitoredWell/Update.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Update.tsx @@ -1,36 +1,38 @@ import { - Modal, + Dialog, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, } from "@mui/material"; -import { - PatchWellMeasurement, -} from "../../../interfaces.js"; -import dayjs from "dayjs"; +import { Save, Delete } from "@mui/icons-material"; +import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); + import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "../../../service/ApiServiceNew"; -import { ModalBackgroundBox } from "./../../"; +import { useGetUserList } from "@/service/ApiServiceNew"; +import { PatchWellMeasurement } from "@/interfaces.js"; export function UpdateModal({ - isMeasurementModalOpen, - handleCloseMeasurementModal, + open, + onClose, measurement, onUpdateMeasurement, onSubmitUpdate, onDeleteMeasurement, }: { - isMeasurementModalOpen: boolean; - handleCloseMeasurementModal: () => void; + open: boolean; + onClose: () => void; measurement: PatchWellMeasurement; onUpdateMeasurement: (value: Partial) => void; onSubmitUpdate: () => void; @@ -39,109 +41,122 @@ export function UpdateModal({ const userList = useGetUserList(); return ( - - - - - Update Measurement - - - - User - - - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - slotProps={{ - textField: { size: "small", fullWidth: true, required: true }, - }} - /> - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null + + + Update Measurement + + + + + + Update the measurement details below. Adjust date/time as needed, + then click Update to save changes. + + + + User + + + + + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null + } + slotProps={{ + textField: { size: "small", fullWidth: true, required: true }, + }} + /> + + + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null + } + /> + + + onUpdateMeasurement({ + value: event.target.value as unknown as number, + }) + } + /> + + + + + + + + ); } diff --git a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx new file mode 100644 index 00000000..4ec5d27a --- /dev/null +++ b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Autocomplete, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + TextField, + Typography, +} from "@mui/material"; +import { DatePicker } from "@mui/x-date-pickers"; +import dayjs, { Dayjs } from "dayjs"; +import { Part, IncreaseQuantityPayload } from "@/interfaces"; +import { Save } from "@mui/icons-material"; + +export const IncreaseQuantityModal = ({ + open, + onClose, + parts, + defaultPartId, + onSubmit, + title = "Increase Part Quantity", +}: { + open: boolean; + onClose: () => void; + parts: Part[]; + defaultPartId?: number | string; + onSubmit: (payload: IncreaseQuantityPayload) => void; + title?: string; +}) => { + const partsById = useMemo(() => { + const map = new Map(); + for (const p of parts) map.set(p.id, p); + return map; + }, [parts]); + + const [selectedPart, setSelectedPart] = useState(null); + const [increaseBy, setIncreaseBy] = useState("1"); + const [date, setDate] = useState(dayjs()); + + const increaseByNum = Number(increaseBy); + + const partError = !selectedPart; + const qtyError = + increaseBy.trim().length === 0 || + Number.isNaN(increaseByNum) || + !Number.isFinite(increaseByNum) || + increaseByNum <= 0; + + // When opening, set defaults (today + optional part) + useEffect(() => { + if (!open) return; + + setDate(dayjs()); + setIncreaseBy("1"); + + if (defaultPartId !== undefined) { + const p = partsById.get(defaultPartId) ?? null; + setSelectedPart(p); + } else { + setSelectedPart(null); + } + }, [open, defaultPartId, partsById]); + + const handleSubmit = () => { + if (!selectedPart || qtyError) return; + + onSubmit({ + partId: selectedPart.id, + increaseBy: Math.trunc(increaseByNum), + date: date?.format("YYYY-MM-DD"), + }); + }; + + return ( + + {title} + + + + + Select a part, enter how many to add, and confirm the date. + + + setSelectedPart(value)} + getOptionLabel={(option) => + option?.part_number + ? `${option.part_number} — ${option.description ?? ""}` + : (option?.description ?? "") + } + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + + setIncreaseBy(e.target.value)} + inputProps={{ min: 1, step: 1 }} + error={qtyError} + helperText={qtyError ? "Enter a number greater than 0." : " "} + /> + + setDate(newDate)} + disableFuture + slotProps={{ + textField: { + helperText: "Defaults to today.", + fullWidth: true, + size: "small", + }, + }} + /> + + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/Parts/index.ts b/frontend/src/components/Modals/Parts/index.ts new file mode 100644 index 00000000..fad3d59b --- /dev/null +++ b/frontend/src/components/Modals/Parts/index.ts @@ -0,0 +1 @@ +export * from "./IncreaseQuantity"; diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index 6208e47a..7ef6942b 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -1,45 +1,50 @@ +import { useState } from "react"; import { - Modal, + Dialog, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, FormControlLabel, Checkbox, } from "@mui/material"; -import { useState } from "react"; +import { RadioButtonUnchecked, TaskAlt, Save } from "@mui/icons-material"; +import { DatePicker, TimePicker } from "@mui/x-date-pickers"; import { useAuthUser } from "react-auth-kit"; +import { useQuery } from "react-query"; import { MonitoredWell, NewRegionMeasurement, SecurityScope, -} from "../../../interfaces.js"; +} from "@/interfaces"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { RadioButtonUnchecked, TaskAlt } from "@mui/icons-material"; -import { useGetUserList } from "../../../service/ApiServiceNew"; -import { useQuery } from "react-query"; -import { useFetchWithAuth } from "../../../hooks/useFetchWithAuth.js"; -import { ModalBackgroundBox } from "./../../"; + +import { useGetUserList } from "@/service/ApiServiceNew"; +import { useFetchWithAuth } from "@/hooks"; export const CreateModal = ({ region_id, //Used to filter wells - isNewMeasurementModalOpen, - handleCloseNewMeasurementModal, + open, + onClose, handleSubmitNewMeasurement, + title = "Create New Measurement", }: { region_id: number; //Used to filter wells - isNewMeasurementModalOpen: boolean; - handleCloseNewMeasurementModal: () => void; + open: boolean; + onClose: () => void; handleSubmitNewMeasurement: (newMeasurement: NewRegionMeasurement) => void; + title?: string; }) => { const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -67,7 +72,7 @@ export const CreateModal = ({ limit: 100, }, }), - enabled: isNewMeasurementModalOpen, + enabled: open, select: (res) => res.items, }); @@ -138,7 +143,9 @@ export const CreateModal = ({ label="Well" > {wells - ?.filter((well: MonitoredWell) => well.chloride_group_id === region_id) + ?.filter( + (well: MonitoredWell) => well.chloride_group_id === region_id, + ) .map((well: MonitoredWell) => ( {well.ra_number} @@ -155,100 +162,119 @@ export const CreateModal = ({ }; return ( - - - - - Create New Measurement - - - - - - - - - - - - } - checkedIcon={} - checked={notSampled} - onChange={(e) => { - const checked = e.target.checked; - setNotSampled(checked) - - if (checked) { - setValue(null); - } - }} - /> - } - label="Well was visited but NOT SAMPLED" - labelPlacement="end" - /> - - - { - const newValue = event.target.value; - setValue(newValue === "" ? null : Number(newValue)); - }} - /> - - - - - {title} + + + + - - - - - + Enter the measurement details below. Date and time default to the + current moment and can be adjusted if needed. + + + + + + + + + } + checkedIcon={} + checked={notSampled} + onChange={(e) => { + const checked = e.target.checked; + setNotSampled(checked); + + if (checked) { + setValue(null); + } + }} + /> + } + label="Well was visited but NOT SAMPLED" + labelPlacement="end" + /> + + { + const newValue = event.target.value; + setValue(newValue === "" ? null : Number(newValue)); + }} + /> + + + + + + + + + + + ); }; diff --git a/frontend/src/components/Modals/Region/Update.tsx b/frontend/src/components/Modals/Region/Update.tsx index 1c5e4dcb..86357e74 100644 --- a/frontend/src/components/Modals/Region/Update.tsx +++ b/frontend/src/components/Modals/Region/Update.tsx @@ -1,56 +1,62 @@ import { useEffect, useState } from "react"; import { - Modal, + Dialog, + DialogActions, + DialogContent, + DialogTitle, TextField, Button, MenuItem, Select, FormControl, InputLabel, - Grid, Typography, + Stack, FormControlLabel, Checkbox, } from "@mui/material"; -import { - MonitoredWell, - PatchRegionMeasurement, -} from "../../../interfaces.js"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import dayjs from "dayjs"; dayjs.extend(utc); dayjs.extend(timezone); + import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { RadioButtonUnchecked, TaskAlt } from "@mui/icons-material"; -import { useGetUserList } from "../../../service/ApiServiceNew"; +import { + RadioButtonUnchecked, + TaskAlt, + Delete, + Save, +} from "@mui/icons-material"; +import { useGetUserList } from "@/service/ApiServiceNew"; import { useQuery } from "react-query"; -import { useFetchWithAuth } from "../../../hooks/useFetchWithAuth.js"; -import { ModalBackgroundBox } from "./../../"; - +import { useFetchWithAuth } from "@/hooks"; +import { MonitoredWell, PatchRegionMeasurement } from "@/interfaces"; export const UpdateModal = ({ region_id, //Used to filter wells - isMeasurementModalOpen, - handleCloseMeasurementModal, + open, + onClose, measurement, onUpdateMeasurement, onSubmitUpdate, onDeleteMeasurement, + title = "Update Measurement", }: { region_id: number; //Used to filter wells - isMeasurementModalOpen: boolean; - handleCloseMeasurementModal: () => void; + open: boolean; + onClose: () => void; measurement: PatchRegionMeasurement; onUpdateMeasurement: (value: Partial) => void; onSubmitUpdate: () => void; onDeleteMeasurement: () => void; + title?: string; }) => { const userList = useGetUserList(); const fetchWithAuth = useFetchWithAuth(); const [notSampled, setNotSampled] = useState( - measurement.value === undefined || measurement.value === null + measurement.value === undefined || measurement.value === null, ); const [previousValue, setPreviousValue] = useState(null); @@ -72,7 +78,7 @@ export const UpdateModal = ({ limit: 100, }, }), - enabled: isMeasurementModalOpen, + enabled: open, select: (res) => res.items, }); @@ -96,160 +102,170 @@ export const UpdateModal = ({ }, [measurement.value]); return ( - - - - - Update Measurement - - - - User - - - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - slotProps={{ - textField: { size: "small", fullWidth: true, required: true }, - }} - /> - - - - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } - /> - - - } - checkedIcon={} - checked={notSampled} - onChange={(e) => handleToggleNotSampled(e.target.checked)} - /> - } - label="Well was visited but NOT SAMPLED" - labelPlacement="end" - /> - - - + {title} + + + + + Update the measurement details below. Adjust date/time as needed, + then click Update to save changes. + + + + User + - onUpdateMeasurement({ - well_id: event.target.value, - }) - } - label="Well" - > - {wells - ?.filter((well: MonitoredWell) => well.chloride_group_id === region_id) - .map((well: MonitoredWell) => ( - - {well.ra_number} - - ))} - {isLoadingWells && ( - - )} - - - - - - + + + + ); -} +}; diff --git a/frontend/src/components/Modals/index.ts b/frontend/src/components/Modals/index.ts index 0e9fb69b..b465421f 100644 --- a/frontend/src/components/Modals/index.ts +++ b/frontend/src/components/Modals/index.ts @@ -1 +1,2 @@ -export * from './Region' +export * from "./Region"; +export * from "./Parts"; diff --git a/frontend/src/interfaces/IncreaseQuantityPayload.ts b/frontend/src/interfaces/IncreaseQuantityPayload.ts new file mode 100644 index 00000000..076a1567 --- /dev/null +++ b/frontend/src/interfaces/IncreaseQuantityPayload.ts @@ -0,0 +1,5 @@ +export interface IncreaseQuantityPayload { + partId: number | string; + increaseBy: number; + date: string; // YYYY-MM-DD +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 4084e6d2..51fdf4f5 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -1,5 +1,6 @@ export * from "./DeviceAttributes"; export * from "./DevicePayload"; +export * from "./IncreaseQuantityPayload"; export * from "./Measurement"; export * from "./SensorAttributes"; export * from "./SensorData"; diff --git a/frontend/src/utils/DateUtils.ts b/frontend/src/utils/DateUtils.ts index e30c4185..f09aa5da 100644 --- a/frontend/src/utils/DateUtils.ts +++ b/frontend/src/utils/DateUtils.ts @@ -9,3 +9,11 @@ export const toGMT6String = (date: Date): string => { hour12: true, }); }; + +export const toYYYYMMDD = (d: Date): string => { + // local date (not UTC) so it matches what users expect + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +}; diff --git a/frontend/src/views/Chlorides/ChloridesTable.tsx b/frontend/src/views/Chlorides/ChloridesTable.tsx index f16aeef7..3f2c8044 100644 --- a/frontend/src/views/Chlorides/ChloridesTable.tsx +++ b/frontend/src/views/Chlorides/ChloridesTable.tsx @@ -1,18 +1,18 @@ import { useMemo } from "react"; import { Box, Button } from "@mui/material"; import { DataGrid, GridPagination, GridColDef } from "@mui/x-data-grid"; -import AddIcon from "@mui/icons-material/Add"; -import { RegionMeasurementDTO } from "../../interfaces"; +import { Add } from "@mui/icons-material"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { useIsAuthenticated } from "react-auth-kit"; +import { RegionMeasurementDTO } from "@/interfaces"; dayjs.extend(utc); dayjs.extend(timezone); declare module "@mui/x-data-grid" { - interface FooterPropsOverrides extends Partial { } + interface FooterPropsOverrides extends Partial {} } interface FooterExtraProps { @@ -60,8 +60,7 @@ export const ChloridesTable = ({ field: "value", headerName: "Chlorides (ppm)", width: 175, - valueFormatter: (value) => - value == null ? "NOT SAMPLED" : value, + valueFormatter: (value) => (value == null ? "NOT SAMPLED" : value), }, { field: "well", @@ -122,8 +121,8 @@ const Footer = ({ size="small" onClick={onOpenModal} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" }, ml: 1.5 }} + startIcon={} > - Create ) : null} diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 0b4d4e3b..09232ec7 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -16,10 +16,7 @@ import { useAuthUser } from "react-auth-kit"; import { useSnackbar } from "notistack"; import { ChloridesTable } from "./ChloridesTable"; import { ChloridesPlot } from "./ChloridesPlot"; -import { - CreateModal, - UpdateModal, -} from "../../components/Modals/Region"; +import { CreateModal, UpdateModal } from "../../components/Modals/Region"; import { NewRegionMeasurement, PatchRegionMeasurement, @@ -105,12 +102,17 @@ export const ChloridesView = () => { }, }), onSuccess: () => { - enqueueSnackbar("Chloride measurement created successfully", { variant: "success" }); + enqueueSnackbar("Chloride measurement created successfully", { + variant: "success", + }); }, onError: (err: any) => { - enqueueSnackbar(`Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); }, }); @@ -131,12 +133,17 @@ export const ChloridesView = () => { }, }), onSuccess: () => { - enqueueSnackbar("Chloride measurement updated successfully", { variant: "success" }); + enqueueSnackbar("Chloride measurement updated successfully", { + variant: "success", + }); }, onError: (err: any) => { - enqueueSnackbar(`Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); }, }); @@ -149,12 +156,17 @@ export const ChloridesView = () => { params: { chloride_measurement_id: levelmeasurement_id }, }), onSuccess: () => { - enqueueSnackbar("Chloride measurement deleted successfully", { variant: "success" }); + enqueueSnackbar("Chloride measurement deleted successfully", { + variant: "success", + }); }, onError: (err: any) => { - enqueueSnackbar(`Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); }, }); @@ -229,8 +241,8 @@ export const ChloridesView = () => { } > Error Loading Data - We couldn’t load chloride data. Please check your connection or try - again. + We couldn’t load chloride data. Please check your connection or + try again. )} { ))} - + { <> setIsNewModalOpen(false)} + open={isNewModalOpen} + onClose={() => setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} + open={isUpdateModalOpen} + onClose={() => setIsUpdateModalOpen(false)} measurement={selectedMeasurement} onUpdateMeasurement={(update) => setSelectedMeasurement({ ...selectedMeasurement, ...update }) diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx index 5eb2043d..582dfb5f 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx @@ -1,12 +1,12 @@ import { useMemo } from "react"; import { Box, Button, Tooltip } from "@mui/material"; import { DataGrid, GridPagination, GridColDef } from "@mui/x-data-grid"; -import AddIcon from "@mui/icons-material/Add"; -import { MonitoredWell, WellMeasurementDTO } from "../../interfaces"; +import { Add } from "@mui/icons-material"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { useIsAuthenticated } from "react-auth-kit"; +import { MonitoredWell, WellMeasurementDTO } from "@/interfaces"; dayjs.extend(utc); dayjs.extend(timezone); @@ -53,7 +53,12 @@ export const MonitoringWellsTable = ({ dayjs.utc(value).tz("America/Denver").format("MM/DD/YYYY hh:mm A"), type: "dateTime", }, - { field: "value", headerName: "Depth to Water (ft)", flex: 1, minWidth: 100 }, + { + field: "value", + headerName: "Depth to Water (ft)", + flex: 1, + minWidth: 100, + }, ]; // Add user column only if logged in @@ -122,9 +127,13 @@ const Footer = ({ size="small" onClick={onOpenModal} disabled={isPlugged} - sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" }, ml: 1.5 }} + sx={{ + flexShrink: 0, + width: { xs: "100%", sm: "auto" }, + ml: 1.5, + }} + startIcon={} > - Create diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index e89921ad..80aa9f15 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -365,13 +365,13 @@ export const MonitoringWellsView = () => { {authUser() && ( <> setIsNewModalOpen(false)} + open={isNewModalOpen} + onClose={() => setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} + open={isUpdateModalOpen} + onClose={() => setIsUpdateModalOpen(false)} measurement={selectedMeasurement} onUpdateMeasurement={(update) => setSelectedMeasurement({ ...selectedMeasurement, ...update }) diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 02c65c9f..ca9810dc 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -21,6 +21,7 @@ import { Part } from "@/interfaces"; import { CustomCardHeader, GridFooterWithButton, + IncreaseQuantityModal, IsTrueChip, TristateToggle, } from "@/components"; @@ -37,6 +38,7 @@ export const PartsTable = ({ const [filteredRows, setFilteredRows] = useState(); const [inUseFilter, setInUseFilter] = useState(); const [commonlyUsedFilter, setCommonlyUsedFilter] = useState(); + const [increaseOpen, setIncreaseOpen] = useState(false); const cols: GridColDef[] = [ { field: "part_number", headerName: "Part Number", width: 150 }, @@ -164,21 +166,26 @@ export const PartsTable = ({ flexShrink: 0, width: { xs: "100%", sm: "auto" }, }} + startIcon={} > - Create @@ -190,6 +197,16 @@ export const PartsTable = ({ + setIncreaseOpen(false)} + parts={partsList.data ?? []} + onSubmit={(payload) => { + console.log("submit", payload); + // call your mutation here + setIncreaseOpen(false); + }} + /> ); }; diff --git a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx index 3532820b..ba0c9f34 100644 --- a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx +++ b/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx @@ -6,23 +6,22 @@ import { DialogContent, DialogContentText, DialogTitle, + Stack, TextField, } from "@mui/material"; -import { - MeterListDTO, - NewWorkOrder, -} from "../../interfaces"; -import MeterSelection from "../../components/MeterSelection"; +import { Save } from "@mui/icons-material"; +import { MeterListDTO, NewWorkOrder } from "@/interfaces"; +import { MeterSelection } from "@/components"; interface NewWorkOrderModalProps { - openNewWorkOrderModal: boolean; - closeNewWorkOrderModal: () => void; + open: boolean; + onClose: () => void; submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; } export function NewWorkOrderModal({ - openNewWorkOrderModal, - closeNewWorkOrderModal, + open, + onClose, submitNewWorkOrder, }: NewWorkOrderModalProps) { const [workOrderTitle, setWorkOrderTitle] = useState(""); @@ -51,7 +50,7 @@ export function NewWorkOrderModal({ title: workOrderTitle, }; submitNewWorkOrder(newWorkOrder); - closeNewWorkOrderModal(); + onClose(); //Reset the form setWorkOrderMeter(undefined); @@ -59,40 +58,68 @@ export function NewWorkOrderModal({ } const handleCancel = () => { - closeNewWorkOrderModal(); + onClose(); 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" : ""} - /> + + 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 19063c5e..8a2d0847 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import DeletedIcon from "@mui/icons-material/Delete"; +import { Delete, Add } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; import HandymanIcon from "@mui/icons-material/Handyman"; import { @@ -291,7 +291,7 @@ export default function WorkOrdersTable() { )} } + icon={} deleteMessage={`Delete work order ${params.id}?`} label="Delete" deleteUser={() => handleDeleteClick(params.id)} @@ -344,8 +344,8 @@ export default function WorkOrdersTable() { size="small" onClick={() => setIsNewWorkOrderModalOpen(true)} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" } }} + startIcon={} > - Create @@ -354,8 +354,8 @@ export default function WorkOrdersTable() { }} /> setIsNewWorkOrderModalOpen(false)} + open={isNewWorkOrderModalOpen} + onClose={() => setIsNewWorkOrderModalOpen(false)} submitNewWorkOrder={handleNewWorkOrder} /> From 3351789b994d78cdde5ba716f4cf2019952ce3c2 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 27 Jan 2026 19:20:44 -0600 Subject: [PATCH 05/91] [src] Patch broken imports --- frontend/src/components/Modals/MonitoredWell/Update.tsx | 2 +- frontend/src/components/Modals/Parts/IncreaseQuantity.tsx | 3 ++- frontend/src/interfaces/IncreaseQuantityPayload.ts | 2 +- frontend/src/views/WorkOrders/WorkOrdersTable.tsx | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Modals/MonitoredWell/Update.tsx b/frontend/src/components/Modals/MonitoredWell/Update.tsx index 93b423b6..698ac2a3 100644 --- a/frontend/src/components/Modals/MonitoredWell/Update.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Update.tsx @@ -13,7 +13,7 @@ import { Stack, } from "@mui/material"; import { Save, Delete } from "@mui/icons-material"; -import dayjs, { Dayjs } from "dayjs"; +import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); diff --git a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx index 4ec5d27a..95f4aa58 100644 --- a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx +++ b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx @@ -12,7 +12,8 @@ import { } from "@mui/material"; import { DatePicker } from "@mui/x-date-pickers"; import dayjs, { Dayjs } from "dayjs"; -import { Part, IncreaseQuantityPayload } from "@/interfaces"; +import { Part } from "@/interfaces"; +import { IncreaseQuantityPayload } from "@/interfaces/IncreaseQuantityPayload"; import { Save } from "@mui/icons-material"; export const IncreaseQuantityModal = ({ diff --git a/frontend/src/interfaces/IncreaseQuantityPayload.ts b/frontend/src/interfaces/IncreaseQuantityPayload.ts index 076a1567..39f030be 100644 --- a/frontend/src/interfaces/IncreaseQuantityPayload.ts +++ b/frontend/src/interfaces/IncreaseQuantityPayload.ts @@ -1,5 +1,5 @@ export interface IncreaseQuantityPayload { partId: number | string; increaseBy: number; - date: string; // YYYY-MM-DD + date: string | undefined; // YYYY-MM-DD } diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 8a2d0847..3dd39309 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from "react"; import { Delete, Add } from "@mui/icons-material"; -import AddIcon from "@mui/icons-material/Add"; import HandymanIcon from "@mui/icons-material/Handyman"; import { DataGrid, From 42e0ed402d002c9d99b9da33fbe97328096660cb Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 01:10:20 -0600 Subject: [PATCH 06/91] [Parts] Update endpoint to reflect current count instead of initial count --- api/models/main_models.py | 33 +++++-- api/routes/parts.py | 94 ++++++++++++-------- api/schemas/part_schemas.py | 8 +- frontend/src/views/Parts/PartDetailsCard.tsx | 6 +- frontend/src/views/Parts/PartsTable.tsx | 2 +- 5 files changed, 95 insertions(+), 48 deletions(-) diff --git a/api/models/main_models.py b/api/models/main_models.py index 4892cc78..5efa446d 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -80,14 +80,29 @@ class Parts(Base): secondary=PartAssociation ) + parts_used_links: Mapped[list["PartsUsed"]] = relationship( + back_populates="part", + cascade="all, delete-orphan", + ) + # Association table that links parts and the meter activity they were used on -PartsUsed = Table( - "PartsUsed", - Base.metadata, - Column("meter_activity_id", ForeignKey("MeterActivities.id"), nullable=False), - Column("part_id", ForeignKey("Parts.id"), nullable=False), -) +class PartsUsed(Base): + __tablename__ = "PartsUsed" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + meter_activity_id: Mapped[int] = mapped_column( + ForeignKey("MeterActivities.id"), nullable=False + ) + part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) + + # nullable in DB; treat null as 1 in queries + count: Mapped[int | None] = mapped_column(Integer, nullable=True) + + part: Mapped["Parts"] = relationship(back_populates="parts_used_links") + meter_activity: Mapped["MeterActivities"] = relationship( + back_populates="parts_used_links" + ) class ServiceTypeLU(Base): @@ -233,7 +248,6 @@ class MeterActivities(Base): activity_type: Mapped["ActivityTypeLU"] = relationship() location: Mapped["Locations"] = relationship() - parts_used: Mapped[List["Parts"]] = relationship("Parts", secondary=PartsUsed) services_performed: Mapped[List["ServiceTypeLU"]] = relationship( "ServiceTypeLU", secondary=ServicesPerformed ) @@ -249,6 +263,11 @@ class MeterActivities(Base): "MeterActivityPhotos", back_populates="meter_activity", cascade="all, delete" ) + parts_used_links: Mapped[list["PartsUsed"]] = relationship( + back_populates="meter_activity", + cascade="all, delete-orphan", + ) + class MeterActivityPhotos(Base): __tablename__ = "MeterActivityPhotos" diff --git a/api/routes/parts.py b/api/routes/parts.py index 42ece499..7448aeae 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,5 +1,5 @@ from fastapi import Depends, APIRouter, HTTPException, Query -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy import select, func from typing import List, Union, Optional from datetime import datetime, date @@ -28,7 +28,7 @@ templates = Environment( loader=FileSystemLoader(TEMPLATES_DIR), - autoescape=select_autoescape(["html", "xml"]) + autoescape=select_autoescape(["html", "xml"]), ) part_router = APIRouter() @@ -42,17 +42,29 @@ ) def get_parts( db: Session = Depends(get_db), - in_use: Optional[bool] = Query( - None, - description="Filter by in_use status" - ), + in_use: Optional[bool] = Query(None, description="Filter by in_use status"), ): - stmt = select(Parts).options(joinedload(Parts.part_type)) + used_sum = func.coalesce(func.sum(func.coalesce(PartsUsed.count, 1)), 0) + current_count = (Parts.count - used_sum).label("current_count") + + stmt = ( + select(Parts, current_count) + .outerjoin(PartsUsed, PartsUsed.part_id == Parts.id) + .options(selectinload(Parts.part_type)) + .group_by(Parts.id) # important for aggregates + ) if in_use is not None: stmt = stmt.where(Parts.in_use == in_use) - return db.scalars(stmt).all() + rows = db.execute(stmt).all() + + results = [] + for part, curr in rows: + part.current_count = curr + results.append(part) + + return results @part_router.get( @@ -73,12 +85,9 @@ def get_parts_used_summary( usage_subq = ( db.query( PartsUsed.c.part_id.label("used_part_id"), - func.count(PartsUsed.c.part_id).label("quantity") - ) - .join( - MeterActivities, - MeterActivities.id == PartsUsed.c.meter_activity_id + func.count(PartsUsed.c.part_id).label("quantity"), ) + .join(MeterActivities, MeterActivities.id == PartsUsed.c.meter_activity_id) .filter( MeterActivities.timestamp_start >= start_dt, MeterActivities.timestamp_start <= end_dt, @@ -94,7 +103,7 @@ def get_parts_used_summary( Parts.part_number, Parts.description, Parts.price, - func.coalesce(usage_subq.c.quantity, 0).label("quantity") + func.coalesce(usage_subq.c.quantity, 0).label("quantity"), ) .outerjoin(usage_subq, Parts.id == usage_subq.c.used_part_id) .filter(Parts.id.in_(parts)) @@ -106,14 +115,16 @@ def get_parts_used_summary( price = row.price or 0 quantity = row.quantity or 0 total = price * quantity - results.append({ - "id": row.id, - "part_number": row.part_number, - "description": row.description, - "price": price, - "quantity": quantity, - "total": total, - }) + results.append( + { + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": quantity, + "total": total, + } + ) return results @@ -130,7 +141,9 @@ def download_parts_used_pdf( db: Session = Depends(get_db), ): # Re-use your existing logic - results = get_parts_used_summary(from_date=from_date, to_date=to_date, parts=parts, db=db) + results = get_parts_used_summary( + from_date=from_date, to_date=to_date, parts=parts, db=db + ) # Add running total just for PDF running_total = 0.0 @@ -151,9 +164,7 @@ def download_parts_used_pdf( return StreamingResponse( pdf_io, media_type="application/pdf", - headers={ - "Content-Disposition": "attachment; filename=parts_used_report.pdf" - }, + headers={"Content-Disposition": "attachment; filename=parts_used_report.pdf"}, ) @@ -174,33 +185,44 @@ def get_part_types(db: Session = Depends(get_db)): tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - selected_part = db.scalars( - select(Parts) + used_sum = func.coalesce(func.sum(func.coalesce(PartsUsed.count, 1)), 0) + current_count = (Parts.count - used_sum).label("current_count") + + row = db.execute( + select(Parts, current_count) + .outerjoin(PartsUsed, PartsUsed.part_id == Parts.id) .where(Parts.id == part_id) .options( - joinedload(Parts.part_type), - joinedload(Parts.meter_types), + selectinload(Parts.part_type), + selectinload(Parts.meter_types), ) + .group_by(Parts.id) ).first() + if not row: + return None + + selected_part, curr = row + selected_part.current_count = curr + # Create the part_schemas.Part instance returned_part = part_schemas.Part.model_validate(selected_part) # If part_type is a Register, we need to load the register details if selected_part and selected_part.part_type.name == "Register": register_details = db.scalars( - select(meterRegisters).where( - meterRegisters.part_id == selected_part.id - ) + select(meterRegisters).where(meterRegisters.part_id == selected_part.id) ).first() - register_details = part_schemas.Register.register_details.model_validate(register_details) + register_details = part_schemas.Register.register_details.model_validate( + register_details + ) # Update the returned_part to include register details returned_part = part_schemas.Register( **returned_part.model_dump(exclude_unset=True), - register_settings=register_details - ) + register_settings=register_details, + ) return returned_part diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 1f147c1a..1c8c5cd5 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -1,3 +1,4 @@ +from typing import Optional from api.schemas.base import ORMBase from api.schemas.meter_schemas import MeterTypeLU @@ -12,6 +13,7 @@ class Part(ORMBase): description: str | None = None vendor: str | None = None count: int + current_count: Optional[int] = None note: str | None = None in_use: bool commonly_used: bool @@ -21,12 +23,14 @@ class Part(ORMBase): part_type: PartTypeLU | None = None meter_types: list[MeterTypeLU] | None = None + class Register(Part): - ''' + """ Adds on register specific fields to the Part model. Note: There is also a MeterRegister schema that is used on the Meters view. I might want to merge these two in the future, but for now they are separate. - ''' + """ + class register_details(ORMBase): brand: str meter_size: float diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index e403a0b1..2f97b413 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -174,7 +174,7 @@ export const PartDetailsCard = ({ @@ -187,7 +187,9 @@ export const PartDetailsCard = ({ type="number" inputProps={{ step: "0.01" }} InputProps={{ - startAdornment: $, + startAdornment: ( + $ + ), }} /> diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index ca9810dc..0da00edc 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -49,7 +49,7 @@ export const PartsTable = ({ width: 200, valueGetter: (params: any) => params?.name, }, - { field: "count", headerName: "Count" }, + { field: "current_count", headerName: "Current Count", width: 150 }, { field: "in_use", headerName: "In Use", From b403c3d968357d914897546bdd2e62c7fbcc9f36 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 01:59:21 -0600 Subject: [PATCH 07/91] [Parts|PartsUsed] Update database table col name & default logic --- api/models/main_models.py | 6 ++-- api/routes/parts.py | 27 ++++++++------- api/schemas/part_schemas.py | 4 ++- frontend/src/interfaces.d.ts | 3 +- frontend/src/views/Parts/PartDetailsCard.tsx | 34 ++++++++----------- ...0128071539_parts_inventory_counts.down.sql | 16 +++++++++ ...260128071539_parts_inventory_counts.up.sql | 27 +++++++++++++++ 7 files changed, 79 insertions(+), 38 deletions(-) create mode 100644 migrations/20260128071539_parts_inventory_counts.down.sql create mode 100644 migrations/20260128071539_parts_inventory_counts.up.sql diff --git a/api/models/main_models.py b/api/models/main_models.py index 5efa446d..0d810d9b 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -64,7 +64,7 @@ class Parts(Base): part_number: Mapped[str] = mapped_column(String, unique=True, nullable=False) description: Mapped[Optional[str]] vendor: Mapped[Optional[str]] - count: Mapped[int] = mapped_column(Integer, default=0) + initial_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) note: Mapped[Optional[str]] in_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) commonly_used: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @@ -86,7 +86,6 @@ class Parts(Base): ) -# Association table that links parts and the meter activity they were used on class PartsUsed(Base): __tablename__ = "PartsUsed" @@ -96,8 +95,7 @@ class PartsUsed(Base): ) part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) - # nullable in DB; treat null as 1 in queries - count: Mapped[int | None] = mapped_column(Integer, nullable=True) + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) part: Mapped["Parts"] = relationship(back_populates="parts_used_links") meter_activity: Mapped["MeterActivities"] = relationship( diff --git a/api/routes/parts.py b/api/routes/parts.py index 7448aeae..84e9eebe 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -44,8 +44,8 @@ def get_parts( db: Session = Depends(get_db), in_use: Optional[bool] = Query(None, description="Filter by in_use status"), ): - used_sum = func.coalesce(func.sum(func.coalesce(PartsUsed.count, 1)), 0) - current_count = (Parts.count - used_sum).label("current_count") + used_sum = func.coalesce(func.sum(PartsUsed.count), 0) + current_count = (Parts.initial_count - used_sum).label("current_count") stmt = ( select(Parts, current_count) @@ -84,16 +84,16 @@ def get_parts_used_summary( usage_subq = ( db.query( - PartsUsed.c.part_id.label("used_part_id"), - func.count(PartsUsed.c.part_id).label("quantity"), + PartsUsed.part_id.label("used_part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("quantity"), ) - .join(MeterActivities, MeterActivities.id == PartsUsed.c.meter_activity_id) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) .filter( MeterActivities.timestamp_start >= start_dt, MeterActivities.timestamp_start <= end_dt, - PartsUsed.c.part_id.in_(parts), + PartsUsed.part_id.in_(parts), ) - .group_by(PartsUsed.c.part_id) + .group_by(PartsUsed.part_id) .subquery() ) @@ -112,8 +112,8 @@ def get_parts_used_summary( results = [] for row in query.all(): - price = row.price or 0 - quantity = row.quantity or 0 + price = float(row.price or 0) + quantity = int(row.quantity or 0) total = price * quantity results.append( { @@ -185,8 +185,8 @@ def get_part_types(db: Session = Depends(get_db)): tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - used_sum = func.coalesce(func.sum(func.coalesce(PartsUsed.count, 1)), 0) - current_count = (Parts.count - used_sum).label("current_count") + used_sum = func.coalesce(func.sum(PartsUsed.count), 0) + current_count = (Parts.initial_count - used_sum).label("current_count") row = db.execute( select(Parts, current_count) @@ -236,8 +236,9 @@ def get_part(part_id: int, db: Session = Depends(get_db)): def update_part(updated_part: part_schemas.Part, db: Session = Depends(get_db)): # Update the part (this won't include secondary attributes like associations) part_db = _get(db, Parts, updated_part.id) + for k, v in updated_part.model_dump(exclude_unset=True).items(): - if k in ["part_type", "meter_types"]: + if k in ["part_type", "meter_types", "current_count"]: continue try: setattr(part_db, k, v) @@ -284,7 +285,7 @@ def create_part(new_part: part_schemas.Part, db: Session = Depends(get_db)): part_type_id=new_part.part_type_id, description=new_part.description, vendor=new_part.vendor, - count=new_part.count, + initial_count=new_part.initial_count, note=new_part.note, in_use=new_part.in_use, commonly_used=new_part.commonly_used, diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 1c8c5cd5..04decc7f 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -12,8 +12,10 @@ class Part(ORMBase): part_number: str description: str | None = None vendor: str | None = None - count: int + + initial_count: int current_count: Optional[int] = None + note: str | None = None in_use: bool commonly_used: bool diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index 50ca0400..24d410ec 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -189,7 +189,8 @@ export interface Part { vendor?: string note?: string description?: string - count?: number + initial_count?: number + current_count?: number in_use: boolean commonly_used: boolean diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index 2f97b413..e0505ee6 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -15,11 +15,7 @@ import { OutlinedInput, Select, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; -import CancelIcon from "@mui/icons-material/Cancel"; +import { Add, Cancel, Edit, Save, SaveAs } from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; @@ -30,16 +26,16 @@ import { useGetMeterTypeList, useGetPart, useUpdatePart, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import ControlledPartTypeSelect from "../../components/RHControlled/ControlledPartTypeSelect"; -import { MeterTypeLU, Part } from "../../interfaces"; -import { ControlledSelectNonObject } from "../../components/RHControlled/ControlledSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +} from "@/service/ApiServiceNew"; +import ControlledTextbox from "@/components/RHControlled/ControlledTextbox"; +import ControlledPartTypeSelect from "@/components/RHControlled/ControlledPartTypeSelect"; +import { ControlledSelectNonObject } from "@/components/RHControlled/ControlledSelect"; +import { CustomCardHeader } from "@/components"; +import { MeterTypeLU, Part } from "@/interfaces"; const PartResolverSchema: Yup.ObjectSchema = Yup.object().shape({ part_number: Yup.string().required("Please enter a part number."), - count: Yup.number() + initial_count: Yup.number() .typeError("Please enter a number.") .required("Please enter a count."), part_type: Yup.mixed().required("Please select a part type."), @@ -129,7 +125,7 @@ export const PartDetailsCard = ({ @@ -172,11 +168,11 @@ export const PartDetailsCard = ({ @@ -231,7 +227,7 @@ export const PartDetailsCard = ({ label={`${value.brand} - ${value.model}`} clickable deleteIcon={ - event.stopPropagation() } @@ -273,7 +269,7 @@ export const PartDetailsCard = ({ variant="contained" onClick={handleSubmit(onAddPart, onErr)} > - +   Save New Part ) : ( @@ -282,7 +278,7 @@ export const PartDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/migrations/20260128071539_parts_inventory_counts.down.sql b/migrations/20260128071539_parts_inventory_counts.down.sql new file mode 100644 index 00000000..21fb6249 --- /dev/null +++ b/migrations/20260128071539_parts_inventory_counts.down.sql @@ -0,0 +1,16 @@ +-- Rename initial_count back to count +ALTER TABLE public."Parts" +RENAME COLUMN initial_count TO count; + +-- Allow NULLs again (original behavior) +ALTER TABLE public."Parts" +ALTER COLUMN count DROP NOT NULL; + +ALTER TABLE public."Parts" +ALTER COLUMN count DROP DEFAULT; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN count DROP NOT NULL; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN count DROP DEFAULT; diff --git a/migrations/20260128071539_parts_inventory_counts.up.sql b/migrations/20260128071539_parts_inventory_counts.up.sql new file mode 100644 index 00000000..c03ccbb4 --- /dev/null +++ b/migrations/20260128071539_parts_inventory_counts.up.sql @@ -0,0 +1,27 @@ +-- Rename Parts.count -> Parts.initial_count +ALTER TABLE public."Parts" +RENAME COLUMN count TO initial_count; + +-- Ensure initial_count is NOT NULL and defaults to 0 +UPDATE public."Parts" +SET initial_count = 0 +WHERE initial_count IS NULL; + +ALTER TABLE public."Parts" +ALTER COLUMN initial_count SET NOT NULL; + +ALTER TABLE public."Parts" +ALTER COLUMN initial_count SET DEFAULT 0; + +-- Normalize PartsUsed.count to 1 +UPDATE public."PartsUsed" +SET count = 1 +WHERE count IS DISTINCT FROM 1; + +-- Enforce count semantics going forward +ALTER TABLE public."PartsUsed" +ALTER COLUMN count SET NOT NULL; + +ALTER TABLE public."PartsUsed" +ALTER COLUMN count SET DEFAULT 1; + From 0cc7fab603412f9f04bd3f778853df4332f0b5ab Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 02:54:12 -0600 Subject: [PATCH 08/91] [Parts] Create parts added table & endpoint --- api/models/main_models.py | 15 +++ api/routes/parts.py | 122 +++++++++++++++-- api/schemas/part_schemas.py | 8 ++ .../Modals/Parts/IncreaseQuantity.tsx | 29 +++- .../src/interfaces/IncreaseQuantityPayload.ts | 5 +- frontend/src/service/ApiServiceNew.ts | 126 ++++++++++++++---- frontend/src/views/Parts/PartsTable.tsx | 20 ++- ...20260128080544_create_parts_added.down.sql | 1 + .../20260128080544_create_parts_added.up.sql | 18 +++ 9 files changed, 297 insertions(+), 47 deletions(-) create mode 100644 migrations/20260128080544_create_parts_added.down.sql create mode 100644 migrations/20260128080544_create_parts_added.up.sql diff --git a/api/models/main_models.py b/api/models/main_models.py index 0d810d9b..2fc32019 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -9,6 +9,7 @@ Boolean, Table, Numeric, + Date, ) from sqlalchemy.orm import ( relationship, @@ -18,6 +19,7 @@ deferred, ) from geoalchemy2.shape import to_shape +from datetime import date from typing import Optional, List @@ -103,6 +105,19 @@ class PartsUsed(Base): ) +class PartsAdded(Base): + __tablename__ = "PartsAdded" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + part_id: Mapped[int] = mapped_column(ForeignKey("Parts.id"), nullable=False) + + count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + date: Mapped[date] = mapped_column(Date, nullable=False) # default handled by DB + note: Mapped[str | None] = mapped_column(String, nullable=True) + + part: Mapped["Parts"] = relationship + + class ServiceTypeLU(Base): """ Describes the type of service performed during an activity diff --git a/api/routes/parts.py b/api/routes/parts.py index 84e9eebe..9248ec44 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -9,6 +9,7 @@ from api.models.main_models import ( Parts, PartsUsed, + PartsAdded, PartAssociation, PartTypeLU, Meters, @@ -44,14 +45,35 @@ def get_parts( db: Session = Depends(get_db), in_use: Optional[bool] = Query(None, description="Filter by in_use status"), ): - used_sum = func.coalesce(func.sum(PartsUsed.count), 0) - current_count = (Parts.initial_count - used_sum).label("current_count") + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") stmt = ( select(Parts, current_count) - .outerjoin(PartsUsed, PartsUsed.part_id == Parts.id) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) .options(selectinload(Parts.part_type)) - .group_by(Parts.id) # important for aggregates ) if in_use is not None: @@ -185,18 +207,39 @@ def get_part_types(db: Session = Depends(get_db)): tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - used_sum = func.coalesce(func.sum(PartsUsed.count), 0) - current_count = (Parts.initial_count - used_sum).label("current_count") + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") row = db.execute( select(Parts, current_count) - .outerjoin(PartsUsed, PartsUsed.part_id == Parts.id) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) .where(Parts.id == part_id) .options( selectinload(Parts.part_type), selectinload(Parts.meter_types), ) - .group_by(Parts.id) ).first() if not row: @@ -339,3 +382,66 @@ def get_meter_parts(meter_id: int, db: Session = Depends(get_db)): ).all() return meter_parts + + +@part_router.post( + "/parts/add", + response_model=part_schemas.Part, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def add_parts(payload: part_schemas.PartsAddRequest, db: Session = Depends(get_db)): + # Ensure part exists + part = db.scalars(select(Parts).where(Parts.id == payload.part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + # Insert PartsAdded row (do NOT mutate Parts.initial_count) + added = PartsAdded( + part_id=payload.part_id, + count=payload.count, + date=payload.date, + note=payload.note, + ) + db.add(added) + db.commit() + + # Return updated part with current_count computed (same formula) + used_subq = ( + select( + PartsUsed.part_id.label("part_id"), + func.coalesce(func.sum(PartsUsed.count), 0).label("used_sum"), + ) + .group_by(PartsUsed.part_id) + .subquery() + ) + + added_subq = ( + select( + PartsAdded.part_id.label("part_id"), + func.coalesce(func.sum(PartsAdded.count), 0).label("added_sum"), + ) + .group_by(PartsAdded.part_id) + .subquery() + ) + + current_count = ( + Parts.initial_count + + func.coalesce(added_subq.c.added_sum, 0) + - func.coalesce(used_subq.c.used_sum, 0) + ).label("current_count") + + row = db.execute( + select(Parts, current_count) + .outerjoin(used_subq, used_subq.c.part_id == Parts.id) + .outerjoin(added_subq, added_subq.c.part_id == Parts.id) + .where(Parts.id == payload.part_id) + .options(selectinload(Parts.part_type), selectinload(Parts.meter_types)) + ).first() + + if not row: + raise HTTPException(status_code=404, detail="Part not found") + + part_obj, curr = row + part_obj.current_count = curr + return part_obj diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 04decc7f..c70f4842 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -1,4 +1,5 @@ from typing import Optional +from datetime import date from api.schemas.base import ORMBase from api.schemas.meter_schemas import MeterTypeLU @@ -48,3 +49,10 @@ class register_details(ORMBase): class PartUsed(ORMBase): part_id: int meter_id: int + + +class PartsAddRequest(ORMBase): + part_id: int + count: int + date: date + note: Optional[str] = None diff --git a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx index 95f4aa58..88283fed 100644 --- a/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx +++ b/frontend/src/components/Modals/Parts/IncreaseQuantity.tsx @@ -23,6 +23,7 @@ export const IncreaseQuantityModal = ({ defaultPartId, onSubmit, title = "Increase Part Quantity", + loading, }: { open: boolean; onClose: () => void; @@ -30,6 +31,7 @@ export const IncreaseQuantityModal = ({ defaultPartId?: number | string; onSubmit: (payload: IncreaseQuantityPayload) => void; title?: string; + loading?: boolean; }) => { const partsById = useMemo(() => { const map = new Map(); @@ -40,6 +42,7 @@ export const IncreaseQuantityModal = ({ const [selectedPart, setSelectedPart] = useState(null); const [increaseBy, setIncreaseBy] = useState("1"); const [date, setDate] = useState(dayjs()); + const [note, setNote] = useState(""); const increaseByNum = Number(increaseBy); @@ -56,6 +59,7 @@ export const IncreaseQuantityModal = ({ setDate(dayjs()); setIncreaseBy("1"); + setNote(""); if (defaultPartId !== undefined) { const p = partsById.get(defaultPartId) ?? null; @@ -69,19 +73,21 @@ export const IncreaseQuantityModal = ({ if (!selectedPart || qtyError) return; onSubmit({ - partId: selectedPart.id, - increaseBy: Math.trunc(increaseByNum), + part_id: selectedPart.id, + count: Math.trunc(increaseByNum), date: date?.format("YYYY-MM-DD"), + note: note.trim().length ? note.trim() : undefined, }); }; return ( {title} @@ -137,6 +143,17 @@ export const IncreaseQuantityModal = ({ }, }} /> + + setNote(e.target.value)} + placeholder="Optional note (e.g., received shipment, inventory correction)" + multiline + minRows={2} + maxRows={4} + /> @@ -149,21 +166,21 @@ export const IncreaseQuantityModal = ({ py: 2, }} > - diff --git a/frontend/src/interfaces/IncreaseQuantityPayload.ts b/frontend/src/interfaces/IncreaseQuantityPayload.ts index 39f030be..46089841 100644 --- a/frontend/src/interfaces/IncreaseQuantityPayload.ts +++ b/frontend/src/interfaces/IncreaseQuantityPayload.ts @@ -1,5 +1,6 @@ export interface IncreaseQuantityPayload { - partId: number | string; - increaseBy: number; + part_id: number | string; + count: number; date: string | undefined; // YYYY-MM-DD + note?: string; } diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 7a96bc04..c6abd7f6 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1,4 +1,10 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient, UseQueryOptions } from "react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + UseQueryOptions, +} from "react-query"; import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { @@ -44,10 +50,11 @@ import { MeterRegister, WaterSource, WellStatus, -} from "../interfaces.js"; -import { WorkOrderStatus } from "../enums"; +} from "@/interfaces"; +import { IncreaseQuantityPayload } from "@/interfaces/IncreaseQuantityPayload"; +import { WorkOrderStatus } from "@/enums"; +import { API_URL } from "@/config"; import { useNavigate } from "react-router-dom"; -import { API_URL } from "../config"; // Date display util export function toGMT6String(date: Date) { @@ -106,9 +113,12 @@ async function GETFetch( navigate: Function, ) { const headers = { Authorization: authHeader }; - const response = await fetch(`${API_URL}/${route}` + formattedQueryParams(params), { - headers: headers, - }); + const response = await fetch( + `${API_URL}/${route}` + formattedQueryParams(params), + { + headers: headers, + }, + ); if (!response.ok) { // If backend indicates that user's token is expired, log them out and notify @@ -238,13 +248,14 @@ export function useGetMeterLocations(searchstring: string | undefined) { return useQuery({ queryKey: [route, searchstring], - queryFn: () => GETFetch( - route, - { search_string: searchstring }, - authHeader(), - signOut, - navigate, - ), + queryFn: () => + GETFetch( + route, + { search_string: searchstring }, + authHeader(), + signOut, + navigate, + ), staleTime: 1000 * 60 * 60 * 24, // 24 hours cacheTime: 1000 * 60 * 60 * 24, // keep in memory for 24 hours refetchOnWindowFocus: false, @@ -422,7 +433,10 @@ export function useGetWells(params: WellListQueryParams | undefined) { ); } -export function useGetWellLocations(searchstring: string | undefined, has_chloride_group: boolean | null = null) { +export function useGetWellLocations( + searchstring: string | undefined, + has_chloride_group: boolean | null = null, +) { const route = "well_locations"; const authHeader = useAuthHeader(); const navigate = useNavigate(); @@ -434,10 +448,15 @@ export function useGetWellLocations(searchstring: string | undefined, has_chlori queryFn: async ({ pageParam = 0 }) => { return GETFetch( route, - { search_string: searchstring, offset: pageParam, limit: PAGE_SIZE, has_chloride_group }, + { + search_string: searchstring, + offset: pageParam, + limit: PAGE_SIZE, + has_chloride_group, + }, authHeader(), signOut, - navigate + navigate, ); }, getNextPageParam: (lastPage, allPages) => { @@ -453,7 +472,6 @@ export function useGetWellLocations(searchstring: string | undefined, has_chlori }); } - export function useGetWell(params: WellDetailsQueryParams | undefined) { const route = "well"; const authHeader = useAuthHeader(); @@ -559,7 +577,7 @@ export function useGetST2WaterLevels(datastreamID: number | undefined) { export function useGetWorkOrders( status_filter: WorkOrderStatus[], - options?: UseQueryOptions + options?: UseQueryOptions, ) { const route = "work_orders"; const authHeader = useAuthHeader(); @@ -568,14 +586,15 @@ export function useGetWorkOrders( return useQuery({ queryKey: [route, { status_filter: status_filter.sort() }], - queryFn: () => GETFetch( - route, - { filter_by_status: status_filter }, - authHeader(), - signOut, - navigate, - ), - ...options + queryFn: () => + GETFetch( + route, + { filter_by_status: status_filter }, + authHeader(), + signOut, + navigate, + ), + ...options, }); } @@ -1590,3 +1609,56 @@ export function useCreateWorkOrder() { retry: 0, }); } + +export function useAddParts(onSuccess?: () => void) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const authHeader = useAuthHeader(); + + const route = "parts/add"; + + return useMutation({ + mutationFn: async (payload: IncreaseQuantityPayload) => { + const response = await POSTFetch(route, payload, authHeader()); + + if (!response.ok) { + if (response.status === 404) { + enqueueSnackbar("Part not found.", { variant: "error" }); + throw new Error("Part not found (404)"); + } + + if (response.status === 422) { + enqueueSnackbar("Missing or invalid fields.", { variant: "error" }); + throw new Error("Validation error (422)"); + } + + // Optional: read backend detail if present + let detail = ""; + try { + const j = await response.json(); + detail = j?.detail ? ` (${j.detail})` : ""; + } catch {} + + enqueueSnackbar( + `Unknown error occurred! (${response.status})${detail}`, + { + variant: "error", + }, + ); + throw new Error(`Unknown Error: ${response.status}${detail}`); + } + + const updatedPart: Part = await response.json(); + + // update any cached parts lists you have + queryClient.setQueryData(["parts"], (old) => { + const safeOld = old ?? []; + return safeOld.map((p) => (p.id === updatedPart.id ? updatedPart : p)); + }); + + onSuccess?.(); + return updatedPart; + }, + retry: 0, + }); +} diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 0da00edc..e85bfe12 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -16,7 +16,7 @@ import { Add, FormatListBulletedOutlined, } from "@mui/icons-material"; -import { useGetParts } from "@/service/ApiServiceNew"; +import { useGetParts, useAddParts } from "@/service/ApiServiceNew"; import { Part } from "@/interfaces"; import { CustomCardHeader, @@ -34,6 +34,7 @@ export const PartsTable = ({ setPartAddMode: Function; }) => { const partsList = useGetParts(); + const addParts = useAddParts(); const [partSearchQuery, setPartSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); const [inUseFilter, setInUseFilter] = useState(); @@ -201,10 +202,21 @@ export const PartsTable = ({ open={increaseOpen} onClose={() => setIncreaseOpen(false)} parts={partsList.data ?? []} + loading={addParts.isLoading} onSubmit={(payload) => { - console.log("submit", payload); - // call your mutation here - setIncreaseOpen(false); + addParts.mutate( + { + part_id: payload.part_id, + count: payload.count, + date: payload.date, + note: payload.note, + }, + { + onSuccess: () => { + setIncreaseOpen(false); + }, + }, + ); }} /> diff --git a/migrations/20260128080544_create_parts_added.down.sql b/migrations/20260128080544_create_parts_added.down.sql new file mode 100644 index 00000000..ad8d4599 --- /dev/null +++ b/migrations/20260128080544_create_parts_added.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS public."PartsAdded"; diff --git a/migrations/20260128080544_create_parts_added.up.sql b/migrations/20260128080544_create_parts_added.up.sql new file mode 100644 index 00000000..87dbdb2f --- /dev/null +++ b/migrations/20260128080544_create_parts_added.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE public."PartsAdded" ( + id serial4 NOT NULL, + part_id int4 NOT NULL, + count int4 NOT NULL DEFAULT 1, + date date NOT NULL DEFAULT CURRENT_DATE, + note varchar NULL, + + CONSTRAINT "PartsAdded_pkey" PRIMARY KEY (id), + CONSTRAINT "PartsAdded_part_id_fkey" + FOREIGN KEY (part_id) + REFERENCES public."Parts"(id) + ON DELETE CASCADE + ON UPDATE CASCADE +); + +-- Helpful indexes +CREATE INDEX "ix_PartsAdded_part_id" ON public."PartsAdded" USING btree (part_id); +CREATE INDEX "ix_PartsAdded_date" ON public."PartsAdded" USING btree (date); From f1c4adc74a1a5a9b5c8c6159643a17c016033992 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 10:21:41 -0600 Subject: [PATCH 09/91] [] --- frontend/src/views/Parts/PartsTable.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index e85bfe12..a8bd1ff5 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -25,6 +25,7 @@ import { IsTrueChip, TristateToggle, } from "@/components"; +import { enqueueSnackbar } from "notistack"; export const PartsTable = ({ setSelectedPartID, @@ -214,6 +215,7 @@ export const PartsTable = ({ { onSuccess: () => { setIncreaseOpen(false); + partsList.refetch(); }, }, ); From 202a0f068baae6cebc9012908b83b7397bcb1942 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 10:26:57 -0600 Subject: [PATCH 10/91] [PartsTable] Add notifications upon success & failure of the quantity increase action --- .../MeterActivityEntry/MeterActivityEntry.tsx | 15 +++++++-------- frontend/src/views/Parts/PartsTable.tsx | 14 +++++++++++++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx index ae974f6e..dc4abc25 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx @@ -1,27 +1,26 @@ -import { useEffect } from "react"; +import { useState, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { useState } from "react"; import { Alert, Box, Button, Stack, Typography } from "@mui/material"; import { useSnackbar } from "notistack"; import { useForm, SubmitHandler } from "react-hook-form"; +import { useMutation } from "react-query"; +import { useAuthHeader } from "react-auth-kit"; import { yupResolver } from "@hookform/resolvers/yup"; +import { ActivityFormControl, MeterListDTO } from "@/interfaces"; +import { ActivityType } from "@/enums"; +import { useGetMeter, useGetWell } from "@/service/ApiServiceNew"; +import { API_URL } from "@/config"; import { MeterActivitySelection } from "./MeterActivitySelection"; import ObservationSelection from "./ObservationsSelection"; import NotesSelection from "./NotesSelection"; import MeterInstallation from "./MeterInstallation"; import MaintenanceRepairSelection from "./MaintenanceRepairSelection"; import PartsSelection from "./PartsSelection"; -import { ActivityFormControl, MeterListDTO } from "../../../interfaces.d"; -import { ActivityType } from "../../../enums"; -import { useGetMeter, useGetWell } from "../../../service/ApiServiceNew"; import { ActivityResolverSchema, getDefaultForm, toSubmissionForm, } from "./ActivityFormConfig"; -import { useMutation } from "react-query"; -import { useAuthHeader } from "react-auth-kit"; -import { API_URL } from "../../../config"; export default function MeterActivityEntry() { const navigate = useNavigate(); diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index a8bd1ff5..1242f578 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -16,6 +16,7 @@ import { Add, FormatListBulletedOutlined, } from "@mui/icons-material"; +import { useSnackbar } from "notistack"; import { useGetParts, useAddParts } from "@/service/ApiServiceNew"; import { Part } from "@/interfaces"; import { @@ -25,7 +26,6 @@ import { IsTrueChip, TristateToggle, } from "@/components"; -import { enqueueSnackbar } from "notistack"; export const PartsTable = ({ setSelectedPartID, @@ -41,6 +41,7 @@ export const PartsTable = ({ const [inUseFilter, setInUseFilter] = useState(); const [commonlyUsedFilter, setCommonlyUsedFilter] = useState(); const [increaseOpen, setIncreaseOpen] = useState(false); + const { enqueueSnackbar } = useSnackbar(); const cols: GridColDef[] = [ { field: "part_number", headerName: "Part Number", width: 150 }, @@ -214,9 +215,20 @@ export const PartsTable = ({ }, { onSuccess: () => { + enqueueSnackbar("Quantity increase submitted successfully.", { + variant: "success", + }); setIncreaseOpen(false); partsList.refetch(); }, + onError: () => { + enqueueSnackbar( + "Failed to submit quantity increase. Please try again.", + { + variant: "error", + }, + ); + }, }, ); }} From c0b8ca336940ccfa46f226fbd6189742882de762 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 16:26:03 -0600 Subject: [PATCH 11/91] [parts] update endpoint & schema to allow for an empty register_settings --- api/routes/parts.py | 10 ++++++---- api/schemas/part_schemas.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 9248ec44..62f7f16b 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -257,14 +257,16 @@ def get_part(part_id: int, db: Session = Depends(get_db)): select(meterRegisters).where(meterRegisters.part_id == selected_part.id) ).first() - register_details = part_schemas.Register.register_details.model_validate( - register_details - ) + register_details_obj = None + if register_details is not None: + register_details_obj = ( + part_schemas.Register.register_details.model_validate(register_details) + ) # Update the returned_part to include register details returned_part = part_schemas.Register( **returned_part.model_dump(exclude_unset=True), - register_settings=register_details, + register_settings=register_details_obj, ) return returned_part diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index c70f4842..776cbf94 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -43,7 +43,7 @@ class register_details(ORMBase): number_of_digits: int | None = None multiplier: float | None = None - register_settings: register_details + register_settings: register_details | None = None class PartUsed(ORMBase): From 9522e16b117d22d830aa50a3f9312865562bfa27 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 28 Jan 2026 19:02:40 -0600 Subject: [PATCH 12/91] [BackupsView] Add /manage/backup view --- api/routes/admin.py | 124 ++++++++++++-- api/schemas/admin_schemas.py | 13 ++ frontend/src/App.tsx | 15 +- frontend/src/constants.ts | 68 +++++++- frontend/src/hooks/useFetchWithAuth.ts | 20 ++- frontend/src/interfaces/BackupRow.ts | 9 + frontend/src/interfaces/index.ts | 1 + frontend/src/sidenav.tsx | 68 ++++---- frontend/src/utils/DateUtils.ts | 14 +- frontend/src/utils/MemoryUtils.ts | 12 ++ frontend/src/utils/index.ts | 15 +- frontend/src/views/Backups/BackupsView.tsx | 190 +++++++++++++++++++++ frontend/src/views/Backups/index.ts | 1 + frontend/src/views/index.ts | 7 +- 14 files changed, 490 insertions(+), 67 deletions(-) create mode 100644 api/schemas/admin_schemas.py create mode 100644 frontend/src/interfaces/BackupRow.ts create mode 100644 frontend/src/utils/MemoryUtils.ts create mode 100644 frontend/src/views/Backups/BackupsView.tsx create mode 100644 frontend/src/views/Backups/index.ts diff --git a/api/routes/admin.py b/api/routes/admin.py index b4eb7d0e..b40e0763 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -1,4 +1,5 @@ -from fastapi import Depends, APIRouter +from fastapi import Depends, APIRouter, HTTPException +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload, undefer from sqlalchemy import select from typing import List @@ -7,6 +8,7 @@ from api.models.main_models import Users, UserRoles, SecurityScopes from api.schemas import security_schemas +from api.schemas import admin_schemas from api.session import get_db from api.route_util import _patch from api.enums import ScopedUser @@ -27,7 +29,8 @@ BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "30")) load_dotenv(os.getenv("APPDB_ENV", ".env")) DATABASE_URL = os.getenv("DATABASE_URL", "") - + + # define response models @admin_router.post( "/users/update_password", @@ -211,11 +214,109 @@ def update_role(updated_role: security_schemas.UserRole, db: Session = Depends(g ).first() +@admin_router.get( + "/db-backups", + response_model=List[admin_schemas.BackupFile], + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def list_db_backups( + signed_expires_minutes: int = 60, + limit: int = 200, +): + if not BUCKET_NAME: + raise HTTPException(status_code=500, detail="GCP_BUCKET_NAME is not set") + + if signed_expires_minutes < 1 or signed_expires_minutes > 24 * 60: + raise HTTPException( + status_code=400, detail="signed_expires_minutes must be between 1 and 1440" + ) + + client = storage.Client() + + prefix = (BACKUP_PREFIX or "").strip("/") + if prefix: + prefix = prefix + "/" + + blobs_iter = client.list_blobs(BUCKET_NAME, prefix=prefix) + + results: list[admin_schemas.BackupFile] = [] + for i, blob in enumerate(blobs_iter): + if i >= limit: + break + + # Skip folder marker objects if any + if blob.name.endswith("/") and (blob.size or 0) == 0: + continue + + # Strip folder prefix for display + display_name = blob.name + if prefix and display_name.startswith(prefix): + display_name = display_name[len(prefix) :] + + # infer format from extension + known pg_dump flags + ext = blob.name.rsplit(".", 1)[-1].lower() if "." in blob.name else "" + if ext == "dump": + fmt = "pg_dump custom (-Fc) (.dump)" + elif ext in ("sql",): + fmt = "plain SQL" + elif ext in ("gz", "gzip"): + fmt = "compressed" + else: + fmt = f"unknown ({ext})" if ext else "unknown" + + results.append( + admin_schemas.BackupFile( + name=display_name, + file_size=int(blob.size or 0), + format=fmt, + gs_uri=f"gs://{BUCKET_NAME}/{blob.name}", + created_utc=blob.time_created, + ) + ) + + # newest first + results.sort(key=lambda x: x.created_utc or 0, reverse=True) + return results + + +@admin_router.get( + "/db-backups/{file_name}/download", + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +async def download_db_backup(file_name: str): + if not BUCKET_NAME: + raise HTTPException(status_code=500, detail="GCP_BUCKET_NAME is not set") + + client = storage.Client() + bucket = client.bucket(BUCKET_NAME) + + prefix = (BACKUP_PREFIX or "").strip("/") + blob_name = f"{prefix}/{file_name}" if prefix else file_name + + blob = bucket.blob(blob_name) + + if not blob.exists(client=client): + raise HTTPException(status_code=404, detail="Backup file not found in storage") + + blob.reload(client=client) + content_type = blob.content_type or "application/octet-stream" + + # Stream from GCS to the client + file_obj = blob.open("rb") + + # Force download + headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} + + return StreamingResponse(file_obj, media_type=content_type, headers=headers) + + @admin_router.api_route( "/backup-db/", methods=["BACKUP"], tags=["Admin"], - dependencies=[Depends(ScopedUser.Admin)] + dependencies=[Depends(ScopedUser.Admin)], ) def backup_and_send(): if not BUCKET_NAME: @@ -227,25 +328,24 @@ def backup_and_send(): timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d-%H%M%S") filename = f"backup-{timestamp}.dump" local_path = Path(f"/tmp/{filename}") - - subprocess.run( - ["pg_dump", "-Fc", DATABASE_URL, "-f", str(local_path)], - check=True - ) + + subprocess.run(["pg_dump", "-Fc", DATABASE_URL, "-f", str(local_path)], check=True) client = storage.Client() bucket = client.bucket(BUCKET_NAME) blob_name = f"{BACKUP_PREFIX}/{filename}" if BACKUP_PREFIX else filename blob = bucket.blob(blob_name) - blob.upload_from_filename(local_path) + blob.upload_from_filename(local_path) print(f"Backup uploaded to gs://{BUCKET_NAME}/{blob_name}") local_path.unlink(missing_ok=True) # Delete old backups (> BACKUP_RETENTION_DAYS) using UTC-aware cutoff - cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=BACKUP_RETENTION_DAYS) + cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + days=BACKUP_RETENTION_DAYS + ) blobs = client.list_blobs(BUCKET_NAME, prefix=BACKUP_PREFIX) deleted = [] @@ -253,8 +353,8 @@ def backup_and_send(): if old_blob.time_created < cutoff_date: old_blob.delete() deleted.append(old_blob.name) - + return { "status": f"Database backup uploaded to gs://{BUCKET_NAME}/{blob_name}", - "deleted_old_backups": deleted + "deleted_old_backups": deleted, } diff --git a/api/schemas/admin_schemas.py b/api/schemas/admin_schemas.py new file mode 100644 index 00000000..6ba18c66 --- /dev/null +++ b/api/schemas/admin_schemas.py @@ -0,0 +1,13 @@ +from typing import Optional +import datetime + +from api.schemas.base import ORMBase +from pydantic import BaseModel + + +class BackupFile(ORMBase): + name: str + file_size: int + format: str + gs_uri: str + created_utc: Optional[datetime.datetime] = None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 494b6e33..b453bc47 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from "react-query"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; import { SnackbarProvider, enqueueSnackbar } from "notistack"; -import { Home, Login, Settings } from "./views"; +import { BackupsView, Home, Login, Settings } from "./views"; import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; import { ActivityPhotoView } from "./views/Activities/ActivityPhotoView"; @@ -279,6 +279,19 @@ export const App = () => { } /> + + + + + + } + /> { route, params = {}, body, + responseType = "json", }: { method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; route: string; params?: Record; body?: any; + responseType?: "json" | "blob" | "text" | "response"; }) => { const url = `${API_URL}${route}${formatQueryParams(params)}`; @@ -27,7 +29,10 @@ export const useFetchWithAuth = () => { method, headers: { Authorization: authHeader(), - "Content-Type": "application/json", + // Only set JSON content-type when sending JSON + ...(body && ["PATCH", "POST", "PUT", "DELETE"].includes(method) + ? { "Content-Type": "application/json" } + : {}), }, body: body && ["PATCH", "POST", "PUT", "DELETE"].includes(method) @@ -47,11 +52,22 @@ export const useFetchWithAuth = () => { variant: "error", }); } + + // try to read error body if available + let detail = ""; + try { + detail = await response.text(); + } catch {} throw new Error( - `[ERROR] HTTP Status: ${response.status} - ${response.statusText}`, + `[ERROR] HTTP Status: ${response.status} - ${response.statusText}${ + detail ? ` - ${detail}` : "" + }`, ); } + if (responseType === "response") return response; + if (responseType === "blob") return response.blob(); + if (responseType === "text") return response.text(); return response.json(); }; }; diff --git a/frontend/src/interfaces/BackupRow.ts b/frontend/src/interfaces/BackupRow.ts new file mode 100644 index 00000000..ec95b4a1 --- /dev/null +++ b/frontend/src/interfaces/BackupRow.ts @@ -0,0 +1,9 @@ +export interface BackupRow { + id: string; // DataGrid requires an id + name: string; + file_size: number; + format: string; + gs_uri: string; + signed_url?: string | null; + created_utc?: string | null; // ISO string +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 51fdf4f5..622de7a6 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -1,3 +1,4 @@ +export * from "./BackupRow"; export * from "./DeviceAttributes"; export * from "./DevicePayload"; export * from "./IncreaseQuantityPayload"; diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index af659122..a9198eb7 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -9,15 +9,11 @@ import { List, ListSubheader, Toolbar, - Typography + Typography, } from "@mui/material"; import { useNavigate } from "react-router-dom"; import { ChevronLeft } from "@mui/icons-material"; -import { - NavLink, - ReportsNavItem, - RoleChip -} from "./components"; +import { NavLink, ReportsNavItem, RoleChip } from "./components"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { SecurityScope, WorkOrder } from "./interfaces"; @@ -39,8 +35,8 @@ export default function Sidenav({ // Normalize scopes into a Set for O(1) lookups const scopes: Set = new Set( authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string - ) ?? [] + (scope: SecurityScope) => scope.scope_string, + ) ?? [], ); const hasReadScope = scopes.has("read"); @@ -50,14 +46,16 @@ export default function Sidenav({ const openWorkOrdersQuery = useGetWorkOrders([WorkOrderStatus.Open], { refetchInterval: 45_000, refetchIntervalInBackground: true, - enabled: hasReadScope && !!authUser() + enabled: hasReadScope && !!authUser(), }); useEffect(() => { if (openWorkOrdersQuery.data && userId) { - setWorkOrderCount(openWorkOrdersQuery.data.filter( - (workOrder: WorkOrder) => workOrder.assigned_user_id === userId - )?.length ?? 0); + setWorkOrderCount( + openWorkOrdersQuery.data.filter( + (workOrder: WorkOrder) => workOrder.assigned_user_id === userId, + )?.length ?? 0, + ); } }, [openWorkOrdersQuery.data, userId]); @@ -131,16 +129,16 @@ export default function Sidenav({ px: "1rem", }} > - - Pages - - }> + Pages}> {navConfig - .filter(item => !item.role) - .map(item => ( - + .filter((item) => !item.role) + .map((item) => ( + ))} {hasReadScope && ( <> @@ -148,22 +146,27 @@ export default function Sidenav({ Pages {navConfig - .filter(item => item.role === "Technician" && !item.parent) - .map(item => ( + .filter((item) => item.role === "Technician" && !item.parent) + .map((item) => ( ))} - + {navConfig - .filter(item => item.parent === "reports") - .map(item => ( + .filter((item) => item.parent === "reports") + .map((item) => ( Pages {navConfig - .filter(item => item.role === "Admin") - .map(item => ( - + .filter((item) => item.role === "Admin") + .map((item) => ( + ))} )} diff --git a/frontend/src/utils/DateUtils.ts b/frontend/src/utils/DateUtils.ts index f09aa5da..d7fec79a 100644 --- a/frontend/src/utils/DateUtils.ts +++ b/frontend/src/utils/DateUtils.ts @@ -10,10 +10,18 @@ export const toGMT6String = (date: Date): string => { }); }; -export const toYYYYMMDD = (d: Date): string => { - // local date (not UTC) so it matches what users expect +export function toYYYYMMDD(d: Date): string; +export function toYYYYMMDD(iso?: string | null): string; + +export function toYYYYMMDD(input?: Date | string | null): string { + if (!input) return "-"; + + const d = input instanceof Date ? input : new Date(input); + if (Number.isNaN(d.getTime())) return String(input); + const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; -}; +} diff --git a/frontend/src/utils/MemoryUtils.ts b/frontend/src/utils/MemoryUtils.ts new file mode 100644 index 00000000..cdffc871 --- /dev/null +++ b/frontend/src/utils/MemoryUtils.ts @@ -0,0 +1,12 @@ +export const formatBytes = (bytes: number): string => { + if (!Number.isFinite(bytes)) return "-"; + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min( + Math.floor(Math.log(bytes) / Math.log(k)), + sizes.length - 1, + ); + const value = bytes / Math.pow(k, i); + return `${value.toFixed(i === 0 ? 0 : 1)} ${sizes[i]}`; +}; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index cf97aab3..23db1355 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,7 +1,8 @@ -export * from "./DateUtils" -export * from "./EmptyToNull" -export * from "./HttpUtils" -export * from "./GetMeterMarkerColor" -export * from "./GetRoleColor" -export * from "./MonitoredWellsUtils" -export * from "./NumberDataFormatter" +export * from "./DateUtils"; +export * from "./EmptyToNull"; +export * from "./HttpUtils"; +export * from "./GetMeterMarkerColor"; +export * from "./GetRoleColor"; +export * from "./MemoryUtils"; +export * from "./MonitoredWellsUtils"; +export * from "./NumberDataFormatter"; diff --git a/frontend/src/views/Backups/BackupsView.tsx b/frontend/src/views/Backups/BackupsView.tsx new file mode 100644 index 00000000..7868c29a --- /dev/null +++ b/frontend/src/views/Backups/BackupsView.tsx @@ -0,0 +1,190 @@ +import { useCallback, useMemo, useState } from "react"; +import { + Alert, + AlertTitle, + Button, + Card, + CardContent, + CircularProgress, + Grid, + Stack, +} from "@mui/material"; +import { Storage, Refresh, Download } from "@mui/icons-material"; +import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { useQuery } from "react-query"; +import { BackupRow } from "@/interfaces/BackupRow"; +import { useFetchWithAuth } from "@/hooks"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +import { toYYYYMMDD, formatBytes } from "@/utils"; + +export const BackupsView = () => { + const fetchWithAuth = useFetchWithAuth(); + const [downloading, setDownloading] = useState>({}); + + const { data, isLoading, error, refetch, isFetching } = useQuery< + BackupRow[], + Error + >({ + queryKey: ["db-backups"], + queryFn: async () => { + const res = await fetchWithAuth({ + method: "GET", + route: "/db-backups", + params: { + include_signed_urls: false, + signed_expires_minutes: 60, + limit: 500, + }, + }); + + return (res ?? []).map((b: any) => ({ + id: b.name, // unique per object + name: b.name, + file_size: b.file_size ?? 0, + format: b.format ?? "unknown", + gs_uri: b.gs_uri, + created_utc: b.created_utc ?? null, + })); + }, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); + + const handleDownload = useCallback( + async (fileName: string) => { + try { + setDownloading((prev) => ({ ...prev, [fileName]: true })); + + const blob = (await fetchWithAuth({ + method: "GET", + route: `/db-backups/${encodeURIComponent(fileName)}/download`, + responseType: "blob", + })) as Blob; + + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } finally { + setDownloading((prev) => ({ ...prev, [fileName]: false })); + } + }, + [fetchWithAuth, setDownloading], + ); + + const columns = useMemo[]>( + () => [ + { + field: "name", + headerName: "Name", + flex: 1, + minWidth: 280, + }, + { + field: "file_size", + headerName: "Size", + width: 150, + valueFormatter: (value) => formatBytes(Number(value ?? 0)), + }, + { + field: "created_utc", + headerName: "Created", + width: 225, + valueFormatter: (value) => + toYYYYMMDD((value as string | null | undefined) ?? null), + }, + { + field: "actions", + headerName: "Actions", + width: 200, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => { + const fileName = params.row.name; + const isDownloading = !!downloading[fileName]; + + return ( + + + + + + ); + }, + }, + ], + [downloading, handleDownload], + ); + + return ( + + + + + {error && ( + refetch()} + startIcon={} + > + Retry + + } + > + Error Loading Backups + We couldn’t load backup data. Please try again. + + )} + + +
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/views/Backups/index.ts b/frontend/src/views/Backups/index.ts new file mode 100644 index 00000000..59eecd72 --- /dev/null +++ b/frontend/src/views/Backups/index.ts @@ -0,0 +1 @@ +export * from "./BackupsView"; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index cde7f1e1..38f6ed6f 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -1,3 +1,4 @@ -export * from './Home' -export * from './Login' -export * from './Settings' +export * from "./Backups"; +export * from "./Home"; +export * from "./Login"; +export * from "./Settings"; From 48f32ca1cf87d9097703006d8dfacb04c5c7eddc Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 4 Feb 2026 09:31:58 -0600 Subject: [PATCH 13/91] [/scripts] Add export_chloride_concentrations.sql --- scripts/export_chloride_concentrations.sql | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 scripts/export_chloride_concentrations.sql diff --git a/scripts/export_chloride_concentrations.sql b/scripts/export_chloride_concentrations.sql new file mode 100644 index 00000000..491bcc02 --- /dev/null +++ b/scripts/export_chloride_concentrations.sql @@ -0,0 +1,50 @@ +/* + Chloride Concentration Results Export (Hydrologist-Friendly CSV) + + This query exports all chloride concentration sample results + from the WellMeasurements table in a format suitable for + non-technical users (hydrologists, consultants, regulators). + + - Sample Result Date is formatted as YYYY-MM-DD (date only) + - Result Value and Result Unit are in separate columns + - Well and Location identifiers are human-readable (no DB IDs) + - Geometry is exported as WKT for GIS compatibility +*/ +SELECT + "Well Name", + "RA Number", + "Sample Result Date", + "Sample Result Value", + "Sample Result Unit", + "Parameter", + "Location Name", + "Latitude", + "Longitude", + "Location Geometry (WKT)" +FROM ( + SELECT + l.name AS "Location Name", + w.name AS "Well Name", + w.ra_number AS "RA Number", + to_char(wm."timestamp"::date, 'YYYY-MM-DD') AS "Sample Result Date", + opt.name AS "Parameter", + wm.value AS "Sample Result Value", + u.name_short AS "Sample Result Unit", + l.latitude AS "Latitude", + l.longitude AS "Longitude", + ST_AsText(l.geom) AS "Location Geometry (WKT)" + FROM public."WellMeasurements" wm + JOIN public."Units" u + ON u.id = wm.unit_id + JOIN public."ObservedPropertyTypeLU" opt + ON opt.id = wm.observed_property_id + JOIN public."Wells" w + ON w.id = wm.well_id + JOIN public."Locations" l + ON l.id = w.location_id + WHERE wm.observed_property_id = 5 +) t +ORDER BY + t."Sample Result Date" ASC, + t."Well Name" ASC, + t."Location Name" ASC; From e6f473ccedfeedc1b874ddcaca2ff44e1a0486b8 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 4 Feb 2026 10:55:22 -0600 Subject: [PATCH 14/91] [well_measurements] Add waterlevels/report-averages endpoint & table to frontend --- api/routes/well_measurements.py | 115 +++++- .../src/interfaces/ReportAveragesResponse.ts | 15 + frontend/src/interfaces/index.ts | 1 + .../views/Reports/MonitoringWells/index.tsx | 361 +++++++++++++----- 4 files changed, 392 insertions(+), 100 deletions(-) create mode 100644 frontend/src/interfaces/ReportAveragesResponse.ts diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 4513fd1e..37a7afcb 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -5,7 +5,7 @@ from fastapi import Depends, APIRouter, Query, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, func from weasyprint import HTML from io import BytesIO @@ -336,6 +336,119 @@ def add_year_average(year: int, label: str): return response_data +@public_well_measurement_router.get( + "/waterlevels/report-averages", + tags=["WaterLevels"], +) +def read_waterlevel_report_averages( + well_ids: List[int] = Query(..., description="One or more well IDs"), + from_date: Optional[date] = Query( + None, description="Start date in ISO format, 'YYYY-MM-DD' (optional)" + ), + to_date: Optional[date] = Query( + None, description="End date in ISO format, 'YYYY-MM-DD' (optional)" + ), + db: Session = Depends(get_db), +): + """ + Report aggregates: + - per-well average depth-to-water for the derived bucket (month or year) + - all-wells average depth-to-water for the derived bucket (month or year) + + Bucket is derived from range: + >= 365 days => year buckets + else => month buckets + """ + DEPTH_TO_WATER_NAME = "Depth to water" + + if not well_ids: + return {"bucket": None, "per_well": [], "all_wells": []} + + if from_date is None and to_date is None: + raise HTTPException( + status_code=400, detail="from_date and/or to_date is required for reports" + ) + + # Build datetime bounds (inclusive end-of-day for to_date) + start_dt = datetime.combine(from_date, datetime.min.time()) if from_date else None + end_dt = datetime.combine(to_date, datetime.max.time()) if to_date else None + + # Decide bucket granularity based on provided range + # If one side missing, fall back to month (or choose a rule you prefer) + if from_date and to_date: + delta_days = (to_date - from_date).days + bucket_unit = "year" if delta_days >= 365 else "month" + else: + bucket_unit = "month" + + bucket = func.date_trunc(bucket_unit, WellMeasurements.timestamp).label( + "period_start" + ) + + base_filters = [ + ObservedPropertyTypeLU.name == DEPTH_TO_WATER_NAME, + WellMeasurements.well_id.in_(well_ids), + ] + if start_dt: + base_filters.append(WellMeasurements.timestamp >= start_dt) + if end_dt: + base_filters.append(WellMeasurements.timestamp <= end_dt) + + # 1) Per-well averages + per_well_stmt = ( + select( + WellMeasurements.well_id.label("well_id"), + Wells.ra_number.label("ra_number"), + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(WellMeasurements.well_id, Wells.ra_number, bucket) + .order_by(Wells.ra_number, bucket) + ) + per_well_rows = db.execute(per_well_stmt).all() + + all_wells_stmt = ( + select( + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(bucket) + .order_by(bucket) + ) + all_wells_rows = db.execute(all_wells_stmt).all() + + return { + "bucket": bucket_unit, # "month" or "year" + "per_well": [ + { + "well_id": r.well_id, + "ra_number": r.ra_number, + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in per_well_rows + ], + "all_wells": [ + { + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in all_wells_rows + ], + } + + @authenticated_well_measurement_router.get( "/waterlevels/pdf", dependencies=[Depends(ScopedUser.Read)], diff --git a/frontend/src/interfaces/ReportAveragesResponse.ts b/frontend/src/interfaces/ReportAveragesResponse.ts new file mode 100644 index 00000000..45aa6fcc --- /dev/null +++ b/frontend/src/interfaces/ReportAveragesResponse.ts @@ -0,0 +1,15 @@ +export interface ReportAverageRow { + period_start: string; // ISO date string + avg_value: number | null; +} + +export type ReportPerWellAverageRow = ReportAverageRow & { + well_id: number; + ra_number: string; +}; + +export interface ReportAveragesResponse { + bucket: "month" | "year" | null; + per_well: ReportPerWellAverageRow[]; + all_wells: ReportAverageRow[]; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 622de7a6..5262ecb7 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -3,5 +3,6 @@ export * from "./DeviceAttributes"; export * from "./DevicePayload"; export * from "./IncreaseQuantityPayload"; export * from "./Measurement"; +export * from "./ReportAveragesResponse"; export * from "./SensorAttributes"; export * from "./SensorData"; diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index fc93959b..70fc9c9f 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -23,27 +23,25 @@ import { Typography, useTheme, } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { LineChart } from "@mui/x-charts"; import { css } from "@emotion/react"; import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledAutocomplete from "../../../components/RHControlled/ControlledAutocomplete"; +import { useAuthHeader } from "react-auth-kit"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; -import { BackgroundBox } from "../../../components/BackgroundBox"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { - LineChart, -} from "@mui/x-charts"; -import { MonitoredWell, WellMeasurementDTO } from "../../../interfaces"; -import { useFetchWithAuth } from "../../../hooks"; -import { separateAndSortMonitoredWells } from "../../../utils"; -import { API_URL } from "../../../config"; -import { useAuthHeader } from "react-auth-kit"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +import ControlledDatepicker from "@/components/RHControlled/ControlledDatepicker"; +import ControlledAutocomplete from "@/components/RHControlled/ControlledAutocomplete"; +import { MonitoredWell, WellMeasurementDTO } from "@/interfaces"; +import { ReportAveragesResponse } from "@/interfaces/ReportAveragesResponse"; +import { useFetchWithAuth } from "@/hooks"; +import { separateAndSortMonitoredWells } from "@/utils"; +import { API_URL } from "@/config"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -51,7 +49,7 @@ const schema = yup.object().shape({ .mixed() .nullable() .required("To date is required") - .test("is-after", "'To' date must be after 'From'", function(value) { + .test("is-after", "'To' date must be after 'From'", function (value) { const { from } = this.parent; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), @@ -67,7 +65,7 @@ const schema = yup.object().shape({ outside_recorder: yup.boolean().nullable(), chloride_group_id: yup.number().nullable(), group: yup.string().nullable(), - }) + }), ) .min(1, "At least one Well is required"), isAveragingAllWells: yup.boolean().required(), @@ -76,8 +74,8 @@ const schema = yup.object().shape({ }); const defaultSchema = { - from: dayjs().startOf('month'), - to: dayjs().endOf('month'), + from: dayjs().startOf("month"), + to: dayjs().endOf("month"), wells: [], isAveragingAllWells: false, isComparingTo1970Average: false, @@ -95,29 +93,33 @@ export const MonitoringWellsReportView = () => {   transition: background-color 0.2s ease; `; const selectedStyle = (isOutside: boolean, theme: any) => css` - background-color: ${isOutside + background-color: ${isOutside ? theme.palette.secondary.dark : theme.palette.primary.dark} !important; - color: ${isOutside + color: ${isOutside ? theme.palette.secondary.contrastText : theme.palette.primary.contrastText} !important; - font-weight: 500; -`; + font-weight: 500; + `; const hoverStyle = (isOutside: boolean, theme: any) => css` - &:hover { - background-color: ${isOutside - ? theme.palette.secondary.main - : theme.palette.primary.main} !important; - color: ${isOutside - ? theme.palette.secondary.contrastText - : theme.palette.primary.contrastText} !important; - } -`; + &:hover { + background-color: ${isOutside + ? theme.palette.secondary.main + : theme.palette.primary.main} !important; + color: ${isOutside + ? theme.palette.secondary.contrastText + : theme.palette.primary.contrastText} !important; + } + `; const authHeader = useAuthHeader(); const fetchWithAuth = useFetchWithAuth(); - const monitoredWellsQuery = useQuery<{ items: MonitoredWell[] }, Error, MonitoredWell[]>({ + const monitoredWellsQuery = useQuery< + { items: MonitoredWell[] }, + Error, + MonitoredWell[] + >({ queryKey: ["wells"], queryFn: () => fetchWithAuth({ @@ -138,18 +140,21 @@ export const MonitoringWellsReportView = () => { }); const wells = watch("wells"); - const wellIds = useMemo(() => wells?.map(w => w.id) ?? [], [wells]); + const wellIds = useMemo(() => wells?.map((w) => w.id) ?? [], [wells]); const from = watch("from"); const to = watch("to"); - const isAveragingAllWells = watch('isAveragingAllWells'); - const isComparingTo1970Average = watch('isComparingTo1970Average'); - const comparisonYear = watch('comparisonYear'); + const isAveragingAllWells = watch("isAveragingAllWells"); + const isComparingTo1970Average = watch("isComparingTo1970Average"); + const comparisonYear = watch("comparisonYear"); useEffect(() => { - if (((wells?.length ?? 0) < 2) && isAveragingAllWells) { - setValue("isAveragingAllWells", false, { shouldDirty: true, shouldValidate: true }); + if ((wells?.length ?? 0) < 2 && isAveragingAllWells) { + setValue("isAveragingAllWells", false, { + shouldDirty: true, + shouldValidate: true, + }); } }, [wells, isAveragingAllWells, setValue]); @@ -161,7 +166,7 @@ export const MonitoringWellsReportView = () => { to, isAveragingAllWells, isComparingTo1970Average, - comparisonYear + comparisonYear, ], queryFn: () => { const searchParams = new URLSearchParams({ @@ -169,7 +174,7 @@ export const MonitoringWellsReportView = () => { to_date: to?.format("YYYY-MM-DD"), isAveragingAllWells: isAveragingAllWells.toString(), isComparingTo1970Average: isComparingTo1970Average.toString(), - comparisonYear: comparisonYear ? comparisonYear.toString() : "" + comparisonYear: comparisonYear ? comparisonYear.toString() : "", }); wellIds.forEach((id: number) => { @@ -179,7 +184,25 @@ export const MonitoringWellsReportView = () => { return fetchWithAuth({ method: "GET", route: `/waterlevels?${searchParams.toString()}`, - }) + }); + }, + enabled: wellIds.length > 0 && !!from && !!to, + }); + + const reportAveragesQuery = useQuery({ + queryKey: ["reportAverages", wellIds, from, to], + queryFn: () => { + const params = new URLSearchParams({ + from_date: from?.format("YYYY-MM-DD"), + to_date: to?.format("YYYY-MM-DD"), + }); + + wellIds.forEach((id: number) => params.append("well_ids", id.toString())); + + return fetchWithAuth({ + method: "GET", + route: `/waterlevels/report-averages?${params.toString()}`, + }); }, enabled: wellIds.length > 0 && !!from && !!to, }); @@ -198,12 +221,15 @@ export const MonitoringWellsReportView = () => { flex: 1, }, ]; - const tableRows = manualMeasurementsQuery?.data?.map((manualMeasurement: WellMeasurementDTO) => ({ - id: manualMeasurement.id, - date_time: manualMeasurement.timestamp, - depth_to_water: manualMeasurement.value, - well: manualMeasurement.well.ra_number, - })) ?? []; + const tableRows = + manualMeasurementsQuery?.data?.map( + (manualMeasurement: WellMeasurementDTO) => ({ + id: manualMeasurement.id, + date_time: manualMeasurement.timestamp, + depth_to_water: manualMeasurement.value, + well: manualMeasurement.well.ra_number, + }), + ) ?? []; const groupedByWell = useMemo(() => { const groups: Record = {}; @@ -224,7 +250,9 @@ export const MonitoringWellsReportView = () => { // Timeshift ONLY the comparison series that are actually enabled/selected const shouldShift = (isComparingTo1970Average && seriesYear === 1970) || - (comparisonYear !== undefined && !Number.isNaN(comparisonYear) && seriesYear === comparisonYear); + (comparisonYear !== undefined && + !Number.isNaN(comparisonYear) && + seriesYear === comparisonYear); if (shouldShift) { const d = dayjs(timestamp); @@ -249,7 +277,7 @@ export const MonitoringWellsReportView = () => { entries.forEach((e) => { const ts = new Date(e.x).getTime(); if (!isNaN(ts)) timestamps.add(ts); - }) + }), ); return Array.from(timestamps).sort((a, b) => a - b); }, [groupedByWell]); @@ -257,7 +285,7 @@ export const MonitoringWellsReportView = () => { const series = useMemo(() => { return Object.entries(groupedByWell).map(([wellName, entries]) => { const dataMap = new Map( - entries.map((e) => [new Date(e.x).getTime(), e.y]) + entries.map((e) => [new Date(e.x).getTime(), e.y]), ); const data = allTimestamps.map((ts) => { const value = dataMap.get(ts); @@ -271,12 +299,71 @@ export const MonitoringWellsReportView = () => { }); }, [groupedByWell, allTimestamps]); - const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells(monitoredWellsQuery?.data); + const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells( + monitoredWellsQuery?.data, + ); const groupedWells = [ - ...regularWells.map(well => ({ ...well, group: "Wells" })), - ...outsideRecorderWells.map(well => ({ ...well, group: "Outside Recorder Wells" })), + ...regularWells.map((well) => ({ ...well, group: "Wells" })), + ...outsideRecorderWells.map((well) => ({ + ...well, + group: "Outside Recorder Wells", + })), + ]; + + const allWellsLatest = useMemo(() => { + const rows = reportAveragesQuery.data?.all_wells ?? []; + if (!rows.length) return null; + // assume period_start sorts ascending as ISO; if not, sort + const sorted = [...rows].sort( + (a, b) => + dayjs(a.period_start).valueOf() - dayjs(b.period_start).valueOf(), + ); + return sorted[sorted.length - 1]; + }, [reportAveragesQuery.data]); + + const bucketLabel = + reportAveragesQuery.data?.bucket === "year" ? "Year" : "Month"; + + const bucket = reportAveragesQuery.data?.bucket ?? "month"; + + const formatPeriodLabel = (periodStart: string) => { + const d = dayjs(periodStart); + if (!d.isValid()) return periodStart; + + return bucket === "year" ? d.format("YYYY") : d.format("MMM YYYY"); + }; + + const avgColumns: GridColDef[] = [ + { + field: "well", + headerName: "Well", + flex: 1, + }, + { + field: "period", + headerName: bucket === "year" ? "Year" : "Month", + flex: 1, + sortComparator: (a, b) => dayjs(a).valueOf() - dayjs(b).valueOf(), + }, + { + field: "avg", + headerName: "Avg DTW (ft)", + type: "number", + flex: 1, + valueFormatter: (avg?: number | null) => + typeof avg === "number" ? avg?.toFixed(2) : "—", + }, ]; + const avgRows = + reportAveragesQuery.data?.per_well?.map((r) => ({ + id: `${r.well_id}-${r.period_start}`, + period_start: r.period_start, // keep raw + period: formatPeriodLabel(r.period_start), + well: r.ra_number, + avg: r.avg_value, + })) ?? []; + const downloadPDFMutation = useMutation({ mutationFn: async ({ from, @@ -284,7 +371,7 @@ export const MonitoringWellsReportView = () => { wellIds, isAveragingAllWells, isComparingTo1970Average, - comparisonYear + comparisonYear, }: { from: Dayjs; to: Dayjs; @@ -298,7 +385,7 @@ export const MonitoringWellsReportView = () => { to_date: to?.format("YYYY-MM-DD"), isAveragingAllWells: isAveragingAllWells.toString(), isComparingTo1970Average: isComparingTo1970Average.toString(), - comparisonYear + comparisonYear, }); wellIds.forEach((id) => params.append("well_ids", id.toString())); @@ -329,14 +416,14 @@ export const MonitoringWellsReportView = () => { wellIds, isAveragingAllWells, isComparingTo1970Average, - comparisonYear: comparisonYear ? comparisonYear.toString() : "" + comparisonYear: comparisonYear ? comparisonYear.toString() : "", }); }; // 1971 → current year const years = Array.from( { length: new Date().getFullYear() - 1971 + 1 }, - (_, i) => 1971 + i + (_, i) => 1971 + i, ); return ( @@ -359,10 +446,7 @@ export const MonitoringWellsReportView = () => { @@ -406,9 +490,15 @@ export const MonitoringWellsReportView = () => { name="wells" control={control} options={groupedWells} - groupBy={(option: MonitoredWell & { group: string }) => option.group} - getOptionLabel={(option: MonitoredWell) => option?.name ?? "Unnamed Well"} - isOptionEqualToValue={(a: MonitoredWell, b: MonitoredWell) => a.id === b.id} + groupBy={(option: MonitoredWell & { group: string }) => + option.group + } + getOptionLabel={(option: MonitoredWell) => + option?.name ?? "Unnamed Well" + } + isOptionEqualToValue={(a: MonitoredWell, b: MonitoredWell) => + a.id === b.id + } disableClearable={false} multiple renderGroup={(params: any) => ( @@ -435,26 +525,33 @@ export const MonitoringWellsReportView = () => { )} renderTags={(value: MonitoredWell[], getTagProps: any) => - (value as (MonitoredWell & { group: string })[]).map((option, index) => { - const isOutside = option.group === "Outside Recorder Wells"; - return ( - - ); - }) + (value as (MonitoredWell & { group: string })[]).map( + (option, index) => { + const isOutside = + option.group === "Outside Recorder Wells"; + return ( + + ); + }, + ) } - renderOption={(props: any, option: MonitoredWell & { group: string }, { selected }: { selected: boolean }) => { + renderOption={( + props: any, + option: MonitoredWell & { group: string }, + { selected }: { selected: boolean }, + ) => { const isOutside = option.group === "Outside Recorder Wells"; return ( { render={({ field: { value, onChange } }) => ( onChange(e.target.checked)} />} + control={ + onChange(e.target.checked)} + /> + } label="Average DTWs across all wells" /> )} @@ -532,7 +634,9 @@ export const MonitoringWellsReportView = () => { inputRef={field.ref} displayEmpty MenuProps={{ - PaperProps: { style: { maxHeight: 48 * 6.5 + 8, width: 220 } }, + PaperProps: { + style: { maxHeight: 48 * 6.5 + 8, width: 220 }, + }, }} > @@ -545,7 +649,9 @@ export const MonitoringWellsReportView = () => { ))} {fieldState.error && ( - {fieldState.error.message} + + {fieldState.error.message} + )} )} @@ -559,20 +665,25 @@ export const MonitoringWellsReportView = () => { { - const date = dayjs(value); - const isMidnight = date.hour() === 0 && date.minute() === 0; - return isMidnight - ? date.format("MMM D, YYYY") - : date.format("MMM D, YYYY HH:mm"); - } - }]} - yAxis={[{ - reverse: true, - }]} + xAxis={[ + { + data: allTimestamps, + scaleType: "time", + valueFormatter: (value) => { + const date = dayjs(value); + const isMidnight = + date.hour() === 0 && date.minute() === 0; + return isMidnight + ? date.format("MMM D, YYYY") + : date.format("MMM D, YYYY HH:mm"); + }, + }, + ]} + yAxis={[ + { + reverse: true, + }, + ]} series={series} slotProps={{ legend: { @@ -603,6 +714,58 @@ export const MonitoringWellsReportView = () => { }} />
+ + + + Report Averages ({bucketLabel}) + + + + All selected monitoring wells average:{" "} + + {allWellsLatest?.avg_value != null + ? `${allWellsLatest.avg_value.toFixed(2)} ft` + : "—"} + + + + + {from?.format("MMM D, YYYY")} → {to?.format("MMM D, YYYY")} + + + {reportAveragesQuery.isLoading && ( + Loading averages… + )} + {reportAveragesQuery.isError && ( + + Failed to load averages: {reportAveragesQuery.error.message} + + )} + + {!reportAveragesQuery.isLoading && + !reportAveragesQuery.isError && ( + + + + )} + + From d18a7e5bbe4984f07692c3ef74c74b99eb22b0cd Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 4 Feb 2026 11:40:50 -0600 Subject: [PATCH 15/91] [Reports/MonitoringWells] Update UI elements & reformatted the date/time col in results table --- .../views/Reports/MonitoringWells/index.tsx | 124 ++++++++++-------- 1 file changed, 66 insertions(+), 58 deletions(-) diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 70fc9c9f..0f92a639 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -208,19 +208,28 @@ export const MonitoringWellsReportView = () => { }); const columns: GridColDef[] = [ - { field: "date_time", headerName: "Date / Time", flex: 1 }, { - field: "depth_to_water", - headerName: "Depth To Water (ft)", - type: "number", + field: "well", + headerName: "Well", flex: 1, }, { - field: "well", - headerName: "Well", + field: "date_time", + headerName: "Date / Time", + flex: 1, + valueFormatter: (date) => { + if (!date) return "—"; + return dayjs(date).format("MMM D, YYYY h:mm A"); + }, + }, + { + field: "depth_to_water", + headerName: "Depth To Water (ft)", + type: "number", flex: 1, }, ]; + const tableRows = manualMeasurementsQuery?.data?.map( (manualMeasurement: WellMeasurementDTO) => ({ @@ -347,7 +356,7 @@ export const MonitoringWellsReportView = () => { }, { field: "avg", - headerName: "Avg DTW (ft)", + headerName: "Average Depth To Water (ft)", type: "number", flex: 1, valueFormatter: (avg?: number | null) => @@ -700,11 +709,12 @@ export const MonitoringWellsReportView = () => {
- + { }} /> - - - - Report Averages ({bucketLabel}) - - - - All selected monitoring wells average:{" "} - - {allWellsLatest?.avg_value != null - ? `${allWellsLatest.avg_value.toFixed(2)} ft` - : "—"} - + + + Report Averages ({bucketLabel}) + + + + All selected monitoring wells average:{" "} + + {allWellsLatest?.avg_value != null + ? `${allWellsLatest.avg_value.toFixed(2)} ft` + : "—"} + + + + + {from?.format("MMM D, YYYY")} → {to?.format("MMM D, YYYY")} + + + {reportAveragesQuery.isLoading && ( + Loading averages… + )} + {reportAveragesQuery.isError && ( + + Failed to load averages: {reportAveragesQuery.error.message} - - - {from?.format("MMM D, YYYY")} → {to?.format("MMM D, YYYY")} - - - {reportAveragesQuery.isLoading && ( - Loading averages… - )} - {reportAveragesQuery.isError && ( - - Failed to load averages: {reportAveragesQuery.error.message} - - )} - - {!reportAveragesQuery.isLoading && - !reportAveragesQuery.isError && ( - - - - )} - + )} + + {!reportAveragesQuery.isLoading && !reportAveragesQuery.isError && ( + + + + )} From 70fd176908f0fb20a8cc3eb4003a604b39dbe6d4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 4 Feb 2026 17:00:39 -0600 Subject: [PATCH 16/91] [export_chloride_concentrations] Update script --- scripts/export_chloride_concentrations.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/export_chloride_concentrations.sql b/scripts/export_chloride_concentrations.sql index 491bcc02..49d5e2af 100644 --- a/scripts/export_chloride_concentrations.sql +++ b/scripts/export_chloride_concentrations.sql @@ -17,6 +17,8 @@ SELECT "Sample Result Value", "Sample Result Unit", "Parameter", + "Casing", + "Total Depth", "Location Name", "Latitude", "Longitude", @@ -26,6 +28,8 @@ FROM ( l.name AS "Location Name", w.name AS "Well Name", w.ra_number AS "RA Number", + w.casing AS "Casing", + w.total_depth AS "Total Depth", to_char(wm."timestamp"::date, 'YYYY-MM-DD') AS "Sample Result Date", opt.name AS "Parameter", wm.value AS "Sample Result Value", From 01cbea090f6b2e2b62a3e4356f21175a47077758 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 5 Feb 2026 13:05:09 -0800 Subject: [PATCH 17/91] [LICENSE] Update copyright information --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 261eeb9e..a1eee53f 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2023 New Mexico Water Data Initiative Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From a2de8131c1cb287f7d6cae6815a62d1a38d5598b Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 6 Feb 2026 13:30:05 -0600 Subject: [PATCH 18/91] refactor --- .gitignore | 3 ++- frontend/src/App.tsx | 17 ++++++++++---- frontend/src/views/Activities/index.ts | 1 + .../src/views/Chlorides/ChloridesView.tsx | 23 +++++++++++-------- frontend/src/views/Chlorides/index.ts | 1 + frontend/src/views/Meters/index.ts | 1 + frontend/src/views/MonitoringWells/index.ts | 1 + frontend/src/views/Parts/index.ts | 1 + frontend/src/views/UserManagement/index.ts | 1 + frontend/src/views/WellManagement/index.ts | 1 + frontend/src/views/WorkOrders/index.ts | 1 + frontend/src/views/index.ts | 19 +++++++++++---- 12 files changed, 51 insertions(+), 19 deletions(-) create mode 100644 frontend/src/views/Activities/index.ts create mode 100644 frontend/src/views/Chlorides/index.ts create mode 100644 frontend/src/views/Meters/index.ts create mode 100644 frontend/src/views/MonitoringWells/index.ts create mode 100644 frontend/src/views/Parts/index.ts create mode 100644 frontend/src/views/UserManagement/index.ts create mode 100644 frontend/src/views/WellManagement/index.ts create mode 100644 frontend/src/views/WorkOrders/index.ts diff --git a/.gitignore b/.gitignore index 483a01fe..f768459b 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ share/python-wheels/ .installed.cfg *.egg MANIFEST +.python-version # PyInstaller # Usually these files are written by a python script from a template @@ -227,4 +228,4 @@ cython_debug/ /api/backupdb/*.sql # dependencies /node_modules -/frontend/node_modules \ No newline at end of file +/frontend/node_modules diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b453bc47..3bdf0079 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,9 +5,18 @@ import { QueryClient, QueryClientProvider } from "react-query"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; import { SnackbarProvider, enqueueSnackbar } from "notistack"; -import { BackupsView, Home, Login, Settings } from "./views"; -import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; -import { ActivitiesView } from "./views/Activities/ActivitiesView"; + +import { + + + ActivitiesView, + MonitoringWellsView, + BackupsView, + Home, + Login, + Settings, + NotFound +} from "./views"; import { ActivityPhotoView } from "./views/Activities/ActivityPhotoView"; import { MetersView } from "./views/Meters/MetersView"; import { PartsView } from "./views/Parts/PartsView"; @@ -22,8 +31,8 @@ import { MaintenanceReportView } from "./views/Reports/Maintenance"; 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"; import { ProtectedRoute } from "./ProtectedRoute"; export const App = () => { diff --git a/frontend/src/views/Activities/index.ts b/frontend/src/views/Activities/index.ts new file mode 100644 index 00000000..5e3365f9 --- /dev/null +++ b/frontend/src/views/Activities/index.ts @@ -0,0 +1 @@ +export * from './ActivitiesView' diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 09232ec7..1a8d0e78 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -11,24 +11,27 @@ import { AlertTitle, Grid, } from "@mui/material"; +import { Science } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; import { useSnackbar } from "notistack"; -import { ChloridesTable } from "./ChloridesTable"; -import { ChloridesPlot } from "./ChloridesPlot"; -import { CreateModal, UpdateModal } from "../../components/Modals/Region"; +import dayjs, { Dayjs } from "dayjs"; + +import { CreateModal, UpdateModal } from "@/components/Modals/Region"; import { NewRegionMeasurement, PatchRegionMeasurement, SecurityScope, RegionMeasurementDTO, -} from "../../interfaces"; -import dayjs, { Dayjs } from "dayjs"; -import { useFetchWithAuth } from "../../hooks"; -import { Science } from "@mui/icons-material"; -import { BackgroundBox } from "../../components/BackgroundBox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; -import { emptyToNull } from "../../utils"; +} from "@/interfaces"; +import { useFetchWithAuth } from "@/hooks"; +import { + BackgroundBox, + CustomCardHeader +} from "@/components"; +import { emptyToNull } from "@/utils"; +import { ChloridesTable } from "./ChloridesTable"; +import { ChloridesPlot } from "./ChloridesPlot"; export const ChloridesView = () => { const { enqueueSnackbar } = useSnackbar(); diff --git a/frontend/src/views/Chlorides/index.ts b/frontend/src/views/Chlorides/index.ts new file mode 100644 index 00000000..499133d3 --- /dev/null +++ b/frontend/src/views/Chlorides/index.ts @@ -0,0 +1 @@ +export * from './ChloridesView' diff --git a/frontend/src/views/Meters/index.ts b/frontend/src/views/Meters/index.ts new file mode 100644 index 00000000..de3ffd87 --- /dev/null +++ b/frontend/src/views/Meters/index.ts @@ -0,0 +1 @@ +export * from './MetersView' diff --git a/frontend/src/views/MonitoringWells/index.ts b/frontend/src/views/MonitoringWells/index.ts new file mode 100644 index 00000000..43abef67 --- /dev/null +++ b/frontend/src/views/MonitoringWells/index.ts @@ -0,0 +1 @@ +export * from './MonitoringWellsView' diff --git a/frontend/src/views/Parts/index.ts b/frontend/src/views/Parts/index.ts new file mode 100644 index 00000000..e55dff06 --- /dev/null +++ b/frontend/src/views/Parts/index.ts @@ -0,0 +1 @@ +export * from './PartsView' diff --git a/frontend/src/views/UserManagement/index.ts b/frontend/src/views/UserManagement/index.ts new file mode 100644 index 00000000..8350cab9 --- /dev/null +++ b/frontend/src/views/UserManagement/index.ts @@ -0,0 +1 @@ +export * from './UserManagementView' diff --git a/frontend/src/views/WellManagement/index.ts b/frontend/src/views/WellManagement/index.ts new file mode 100644 index 00000000..db545593 --- /dev/null +++ b/frontend/src/views/WellManagement/index.ts @@ -0,0 +1 @@ +export * from './WellManagementView' diff --git a/frontend/src/views/WorkOrders/index.ts b/frontend/src/views/WorkOrders/index.ts new file mode 100644 index 00000000..33fc5515 --- /dev/null +++ b/frontend/src/views/WorkOrders/index.ts @@ -0,0 +1 @@ +export * from './WorkOrdersView' diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index 38f6ed6f..ae5a45e3 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -1,4 +1,15 @@ -export * from "./Backups"; -export * from "./Home"; -export * from "./Login"; -export * from "./Settings"; +export * from './Activities' +export * from './Backups' +export * from './Chlorides' +export * from './Home.ts' +export * from './InsufficientPermView.ts' +export * from './Login.ts' +export * from './Meters' +export * from './MonitoringWells' +export * from './NotFound.ts' +export * from './Parts' +export * from './Reports' +export * from './Settings.ts' +export * from './UserManagement' +export * from './WellManagement' +export * from './WorkOrders' From 2af038aa2e21bda50a4e488c88c2de94ba09fc0f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 9 Feb 2026 22:17:33 -0600 Subject: [PATCH 19/91] chore(RHControlled): Update imports --- frontend/src/App.tsx | 49 +----- .../Modals/MonitoredWell/Create.tsx | 3 +- .../Modals/MonitoredWell/Update.tsx | 2 +- .../RHControlled/ControlledActivitySelect.tsx | 10 +- .../RHControlled/ControlledAutocomplete.tsx | 64 ++++--- .../RHControlled/ControlledCheckbox.tsx | 51 +++--- .../components/RHControlled/ControlledDMS.tsx | 34 ++-- .../RHControlled/ControlledDatepicker.tsx | 30 ++-- .../ControlledMeterRegisterSelect.tsx | 35 ++-- .../RHControlled/ControlledMeterSelection.tsx | 16 +- .../ControlledMeterStatusTypeSelect.tsx | 11 +- .../ControlledMeterTypeSelect.tsx | 11 +- .../RHControlled/ControlledPartTypeSelect.tsx | 11 +- .../RHControlled/ControlledTextbox.tsx | 42 ++--- .../RHControlled/ControlledTimepicker.tsx | 44 ++--- .../RHControlled/ControlledUserSelect.tsx | 13 +- .../RHControlled/ControlledWellSelection.tsx | 13 +- .../RHControlled/NotesChipSelect.tsx | 9 +- .../RHControlled/PartsChipSelect.tsx | 12 +- .../RHControlled/ServicesChipSelect.tsx | 12 +- .../MaintenanceRepairSelection.tsx | 5 +- .../MeterActivitySelection.tsx | 16 +- .../MeterActivityEntry/MeterInstallation.tsx | 15 +- .../ObservationsSelection.tsx | 12 +- frontend/src/views/Activities/index.ts | 3 +- .../src/views/Meters/MeterDetailsFields.tsx | 31 ++-- .../MeterHistory/SelectedActivityDetails.tsx | 54 +++--- .../SelectedObservationDetails.tsx | 34 ++-- .../src/views/Parts/MeterTypeDetailsCard.tsx | 23 ++- frontend/src/views/Parts/PartDetailsCard.tsx | 10 +- frontend/src/views/Reports/Board/index.tsx | 115 ------------ .../src/views/Reports/Chlorides/index.tsx | 147 ++++++++++------ .../src/views/Reports/Maintenance/index.tsx | 16 +- .../views/Reports/MonitoringWells/index.tsx | 9 +- .../src/views/Reports/PartsUsed/index.tsx | 29 +-- .../src/views/Reports/WorkOrders/index.tsx | 166 ------------------ frontend/src/views/Reports/index.tsx | 20 +-- .../views/UserManagement/RoleDetailsCard.tsx | 21 +-- .../views/UserManagement/UserDetailsCard.tsx | 56 +++--- .../views/WellManagement/WellDetailsCard.tsx | 39 ++-- .../WellManagement/WellManagementView.tsx | 16 +- .../src/views/WorkOrders/WorkOrdersView.tsx | 14 +- frontend/src/views/index.ts | 30 ++-- 43 files changed, 523 insertions(+), 830 deletions(-) delete mode 100644 frontend/src/views/Reports/Board/index.tsx delete mode 100644 frontend/src/views/Reports/WorkOrders/index.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3bdf0079..024d349e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,31 +5,26 @@ import { QueryClient, QueryClientProvider } from "react-query"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; import { SnackbarProvider, enqueueSnackbar } from "notistack"; - import { - - ActivitiesView, MonitoringWellsView, BackupsView, Home, Login, Settings, - NotFound + NotFound, + ActivityPhotoView, + MetersView, + PartsView, + UserManagementView, + WellManagementView, + WorkOrdersView, + ChloridesView, + ReportsView, } from "./views"; -import { ActivityPhotoView } from "./views/Activities/ActivityPhotoView"; -import { MetersView } from "./views/Meters/MetersView"; -import { PartsView } from "./views/Parts/PartsView"; -import { UserManagementView } from "./views/UserManagement/UserManagementView"; -import WellManagementView from "./views/WellManagement/WellManagementView"; -import WorkOrdersView from "./views/WorkOrders/WorkOrdersView"; -import { ChloridesView } from "./views/Chlorides/ChloridesView"; -import { ReportsView } from "./views/Reports"; -import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; import { MaintenanceReportView } from "./views/Reports/Maintenance"; import { PartsUsedReportView } from "./views/Reports/PartsUsed"; -import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; import { AppLayout } from "./AppLayout"; @@ -184,19 +179,6 @@ export const App = () => { } /> - - - - - - } - /> { } /> - - - - - - } - /> { const activityTypeList = useGetActivityTypeList(); return ( @@ -21,4 +21,4 @@ export default function ControlledActivitySelect({ value={activityTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx index d474020e..ca0424d3 100644 --- a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx +++ b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx @@ -8,7 +8,7 @@ const disabledInputStyle = { cursor: "default", }; -export default function ControlledAutocomplete({ +export const ControlledAutocomplete = ({ control, name, options = [], @@ -17,37 +17,35 @@ export default function ControlledAutocomplete({ isOptionEqualToValue, multiple = false, ...childProps -}: any) { - return ( - { - const { value, onChange, ...restField } = field; +}: any) => ( + { + const { value, onChange, ...restField } = field; - const safeValue = multiple - ? Array.isArray(value) - ? value - : [] - : value ?? null; + const safeValue = multiple + ? Array.isArray(value) + ? value + : [] + : (value ?? null); - return ( - onChange(newValue)} - sx={disabledInputStyle} - {...childProps} - /> - ); - }} - /> - ); -} + return ( + onChange(newValue)} + sx={disabledInputStyle} + {...childProps} + /> + ); + }} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledCheckbox.tsx b/frontend/src/components/RHControlled/ControlledCheckbox.tsx index da701cca..4d85b486 100644 --- a/frontend/src/components/RHControlled/ControlledCheckbox.tsx +++ b/frontend/src/components/RHControlled/ControlledCheckbox.tsx @@ -8,32 +8,25 @@ const disabledInputStyle = { cursor: "default", }; -export default function ControlledCheckbox({ - name, - control, - ...childProps -}: any) { - return ( - { - //console.log(field) - return ( - - } - {...childProps} - /> - ); - }} - /> - ); -} +export const ControlledCheckbox = ({ name, control, ...childProps }: any) => ( + { + return ( + + } + {...childProps} + /> + ); + }} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledDMS.tsx b/frontend/src/components/RHControlled/ControlledDMS.tsx index 79c4afa9..93b84806 100644 --- a/frontend/src/components/RHControlled/ControlledDMS.tsx +++ b/frontend/src/components/RHControlled/ControlledDMS.tsx @@ -1,8 +1,8 @@ -import React, { useEffect, useState } from "react"; +import { useEffect, useState, forwardRef } from "react"; import { TextField } from "@mui/material"; import { Controller } from "react-hook-form"; -import { GCSdimension } from "../../enums"; import { PatternFormat, PatternFormatProps } from "react-number-format"; +import { GCSdimension } from "@/enums"; interface DMSInputProps { dimension_type: GCSdimension; @@ -16,7 +16,7 @@ interface CustomProps { name: string; } -const DMSFormatCustom = React.forwardRef( +const DMSFormatCustom = forwardRef( function PatternFormatCustom(props, ref) { const { onChange, ...other } = props; @@ -122,18 +122,16 @@ function DMSInput({ dimension_type, value, onChange }: DMSInputProps) { ); } -export default function ControlledDMS({ name, control, ...childProps }: any) { - return ( - ( - field.onChange(newValue)} - /> - )} - /> - ); -} +export const ControlledDMS = ({ name, control, ...childProps }: any) => ( + ( + field.onChange(newValue)} + /> + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledDatepicker.tsx b/frontend/src/components/RHControlled/ControlledDatepicker.tsx index 78055d85..1b0917bf 100644 --- a/frontend/src/components/RHControlled/ControlledDatepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledDatepicker.tsx @@ -1,23 +1,21 @@ import { DatePicker } from "@mui/x-date-pickers"; import { Controller } from "react-hook-form"; -export default function ControlledDatepicker({ +export const ControlledDatepicker = ({ name, control, size = "small", ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +}: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx b/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx index 696d6928..9fea17b5 100644 --- a/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterRegisterSelect.tsx @@ -1,23 +1,22 @@ -import MeterRegisterSelect from "../MeterRegisterSelect"; import { Controller } from "react-hook-form"; -export default function ControlledMeterRegisterSelect({ +import MeterRegisterSelect from "../MeterRegisterSelect"; + +export const ControlledMeterRegisterSelect = ({ control, name, ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +}: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledMeterSelection.tsx b/frontend/src/components/RHControlled/ControlledMeterSelection.tsx index 42b34342..7c1e811d 100644 --- a/frontend/src/components/RHControlled/ControlledMeterSelection.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterSelection.tsx @@ -1,16 +1,18 @@ import { useState } from "react"; import { TextField } from "@mui/material"; import { useDebounce } from "use-debounce"; -import { useGetMeterList } from "../../service/ApiServiceNew"; -import { MeterListDTO } from "../../interfaces"; -import ControlledAutocomplete from "./ControlledAutocomplete"; -import { MeterStatusNames } from "../../enums"; -export default function ControlledMeterSelection({ +import { useGetMeterList } from "@/service/ApiServiceNew"; +import { MeterListDTO } from "@/interfaces"; +import { MeterStatusNames } from "@/enums"; + +import { ControlledAutocomplete } from "./ControlledAutocomplete"; + +export const ControlledMeterSelection = ({ name, control, ...childProps -}: any) { +}: any) => { const [meterSearchQuery, setMeterSearchQuery] = useState(""); const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); @@ -63,4 +65,4 @@ export default function ControlledMeterSelection({ }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx b/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx index 838fd6d6..73a8bc56 100644 --- a/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterStatusTypeSelect.tsx @@ -1,12 +1,13 @@ -import { useGetMeterStatusTypeList } from "../../service/ApiServiceNew"; -import { MeterStatus } from "../../interfaces"; +import { useGetMeterStatusTypeList } from "@/service/ApiServiceNew"; +import { MeterStatus } from "@/interfaces"; + import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledMeterStatusTypeSelect({ +export const ControlledMeterStatusTypeSelect = ({ name, control, ...childProps -}: any) { +}: any) => { const statusTypeList = useGetMeterStatusTypeList(); return ( @@ -21,4 +22,4 @@ export default function ControlledMeterStatusTypeSelect({ value={statusTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx b/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx index 688eec03..23349722 100644 --- a/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledMeterTypeSelect.tsx @@ -1,12 +1,13 @@ -import { useGetMeterTypeList } from "../../service/ApiServiceNew"; -import { MeterTypeLU } from "../../interfaces"; +import { useGetMeterTypeList } from "@/service/ApiServiceNew"; +import { MeterTypeLU } from "@/interfaces"; + import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledMeterTypeSelect({ +export const ControlledMeterTypeSelect = ({ name, control, ...childProps -}: any) { +}: any) => { const meterTypeList = useGetMeterTypeList(); return ( @@ -21,4 +22,4 @@ export default function ControlledMeterTypeSelect({ value={meterTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx b/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx index bbe8b0aa..8c6a491f 100644 --- a/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledPartTypeSelect.tsx @@ -1,12 +1,13 @@ -import { useGetPartTypeList } from "../../service/ApiServiceNew"; -import { PartTypeLU } from "../../interfaces"; +import { useGetPartTypeList } from "@/service/ApiServiceNew"; +import { PartTypeLU } from "@/interfaces"; + import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledPartTypeSelect({ +export const ControlledPartTypeSelect = ({ name, control, ...childProps -}: any) { +}: any) => { const partTypeList = useGetPartTypeList(); return ( @@ -21,4 +22,4 @@ export default function ControlledPartTypeSelect({ value={partTypeList.isLoading ? "Loading..." : childProps.value} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ControlledTextbox.tsx b/frontend/src/components/RHControlled/ControlledTextbox.tsx index a7dd467d..6c1ce009 100644 --- a/frontend/src/components/RHControlled/ControlledTextbox.tsx +++ b/frontend/src/components/RHControlled/ControlledTextbox.tsx @@ -8,27 +8,21 @@ const disabledInputStyle = { cursor: "default", }; -export default function ControlledTextbox({ - name, - control, - ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +export const ControlledTextbox = ({ name, control, ...childProps }: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledTimepicker.tsx b/frontend/src/components/RHControlled/ControlledTimepicker.tsx index 53f2cd85..b8bd5323 100644 --- a/frontend/src/components/RHControlled/ControlledTimepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledTimepicker.tsx @@ -1,28 +1,22 @@ import { TimePicker } from "@mui/x-date-pickers"; import { Controller } from "react-hook-form"; -export default function ControlledTimepicker({ - name, - control, - ...childProps -}: any) { - return ( - ( - - )} - /> - ); -} +export const ControlledTimepicker = ({ name, control, ...childProps }: any) => ( + ( + + )} + /> +); diff --git a/frontend/src/components/RHControlled/ControlledUserSelect.tsx b/frontend/src/components/RHControlled/ControlledUserSelect.tsx index d76b352f..d7cb63d2 100644 --- a/frontend/src/components/RHControlled/ControlledUserSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledUserSelect.tsx @@ -1,16 +1,17 @@ import { useState } from "react"; -import { ControlledSelect } from "./ControlledSelect"; -import { User } from "../../interfaces"; -import { useGetUserList } from "../../service/ApiServiceNew"; import { useAuthUser } from "react-auth-kit"; +import { User } from "@/interfaces"; +import { useGetUserList } from "@/service/ApiServiceNew"; + +import { ControlledSelect } from "./ControlledSelect"; -export default function ControlledUserSelect({ +export const ControlledUserSelect = ({ name, control, hideAndSelectCurrentUser = false, setValue = null, ...childProps -}: any) { +}: any) => { const [isCurrentUserSet, setIsCurrentUserSet] = useState(false); if (!hideAndSelectCurrentUser) { @@ -36,4 +37,4 @@ export default function ControlledUserSelect({ } return null; } -} +}; diff --git a/frontend/src/components/RHControlled/ControlledWellSelection.tsx b/frontend/src/components/RHControlled/ControlledWellSelection.tsx index cb160284..a4ef1615 100644 --- a/frontend/src/components/RHControlled/ControlledWellSelection.tsx +++ b/frontend/src/components/RHControlled/ControlledWellSelection.tsx @@ -1,15 +1,16 @@ import { useState } from "react"; import { TextField } from "@mui/material"; import { useDebounce } from "use-debounce"; -import { useGetWells } from "../../service/ApiServiceNew"; -import { Well } from "../../interfaces"; -import ControlledAutocomplete from "./ControlledAutocomplete"; +import { useGetWells } from "@/service/ApiServiceNew"; +import { Well } from "@/interfaces"; -export default function ControlledWellSelection({ +import { ControlledAutocomplete } from "./ControlledAutocomplete"; + +export const ControlledWellSelection = ({ name, control, ...childProps -}: any) { +}: any) => { const [wellSearchQuery, setWellSearchQuery] = useState(""); const [wellSearchQueryDebounced] = useDebounce(wellSearchQuery, 250); @@ -45,4 +46,4 @@ export default function ControlledWellSelection({ }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/NotesChipSelect.tsx b/frontend/src/components/RHControlled/NotesChipSelect.tsx index b4bf883a..2fc7a097 100644 --- a/frontend/src/components/RHControlled/NotesChipSelect.tsx +++ b/frontend/src/components/RHControlled/NotesChipSelect.tsx @@ -1,9 +1,10 @@ import ChipSelect from "../ChipSelect"; -import { NoteTypeLU } from "../../interfaces"; -import { useGetNoteTypes } from "../../service/ApiServiceNew"; +import { NoteTypeLU } from "@/interfaces"; +import { useGetNoteTypes } from "@/service/ApiServiceNew"; + import { Controller } from "react-hook-form"; -export default function NotesChipSelect({ name, control }: any) { +export const NotesChipSelect = ({ name, control }: any) => { const notesList = useGetNoteTypes(); return ( @@ -44,4 +45,4 @@ export default function NotesChipSelect({ name, control }: any) { }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/PartsChipSelect.tsx b/frontend/src/components/RHControlled/PartsChipSelect.tsx index b7cccb61..5537cc10 100644 --- a/frontend/src/components/RHControlled/PartsChipSelect.tsx +++ b/frontend/src/components/RHControlled/PartsChipSelect.tsx @@ -1,9 +1,11 @@ -import ChipSelect from "../ChipSelect"; -import { Part } from "../../interfaces"; -import { useGetMeterPartsList } from "../../service/ApiServiceNew"; import { Controller } from "react-hook-form"; -export default function PartsChipSelect({ name, control, meterid }: any) { +import { Part } from "@/interfaces"; +import { useGetMeterPartsList } from "@/service/ApiServiceNew"; + +import ChipSelect from "../ChipSelect"; + +export const PartsChipSelect = ({ name, control, meterid }: any) => { const partsList = useGetMeterPartsList({ meter_id: meterid }); return ( @@ -42,4 +44,4 @@ export default function PartsChipSelect({ name, control, meterid }: any) { }} /> ); -} +}; diff --git a/frontend/src/components/RHControlled/ServicesChipSelect.tsx b/frontend/src/components/RHControlled/ServicesChipSelect.tsx index 4a970e19..7bb4e5f0 100644 --- a/frontend/src/components/RHControlled/ServicesChipSelect.tsx +++ b/frontend/src/components/RHControlled/ServicesChipSelect.tsx @@ -1,9 +1,11 @@ -import ChipSelect from "../ChipSelect"; -import { ServiceTypeLU } from "../../interfaces"; -import { useGetServiceTypes } from "../../service/ApiServiceNew"; import { Controller } from "react-hook-form"; -export default function ServicesChipSelect({ name, control }: any) { +import { ServiceTypeLU } from "@/interfaces"; +import { useGetServiceTypes } from "@/service/ApiServiceNew"; + +import ChipSelect from "../ChipSelect"; + +export const ServicesChipSelect = ({ name, control }: any) => { const servicesList = useGetServiceTypes(); return ( @@ -46,4 +48,4 @@ export default function ServicesChipSelect({ name, control }: any) { }} /> ); -} +}; diff --git a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx index d8a7dfff..4457a3ca 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx @@ -1,8 +1,7 @@ import { Box, Grid, Typography } from "@mui/material"; import { useFieldArray } from "react-hook-form"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { StyledToggleButton } from "../../../components"; -import { useGetServiceTypes } from "../../../service/ApiServiceNew"; +import { ControlledTextbox, StyledToggleButton } from "@/components"; +import { useGetServiceTypes } from "@/service/ApiServiceNew"; export default function MaintenanceRepairSelection({ control, diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx index a34539a6..988d1a7c 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx @@ -1,11 +1,13 @@ import { Grid } from "@mui/material"; -import ControlledMeterSelection from "../../../components/RHControlled/ControlledMeterSelection"; -import ControlledActivitySelect from "../../../components/RHControlled/ControlledActivitySelect"; -import ControlledUserSelect from "../../../components/RHControlled/ControlledUserSelect"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; -import ControlledCheckbox from "../../../components/RHControlled/ControlledCheckbox"; -import { ControlledWorkOrderSelect } from "../../../components/RHControlled/ControlledWorkOrderSelect"; +import { + ControlledActivitySelect, + ControlledCheckbox, + ControlledDatepicker, + ControlledMeterSelection, + ControlledTimepicker, + ControlledUserSelect, + ControlledWorkOrderSelect, +} from "@/components"; export function MeterActivitySelection({ control, errors, setValue }: any) { return ( diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterInstallation.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterInstallation.tsx index fda3fcd5..d60e8dad 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterInstallation.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterInstallation.tsx @@ -8,10 +8,9 @@ import { TableRow, Typography, } from "@mui/material"; -import { ActivityType } from "../../../enums"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import ControlledWellSelection from "../../../components/RHControlled/ControlledWellSelection"; -import { formatLatLong } from "../../../conversions"; +import { ActivityType } from "@/enums"; +import { ControlledTextbox, ControlledWellSelection } from "@/components"; +import { formatLatLong } from "@/conversions"; export default function MeterInstallation({ control, errors, watch }: any) { const isActivity = (activitiesList: ActivityType[]) => @@ -62,12 +61,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 ?? "--"} diff --git a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx index 6cf8a7c4..6aa16506 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx @@ -3,11 +3,13 @@ import { Box, Button, Grid, Typography, IconButton } from "@mui/material"; import { UseQueryResult } from "react-query"; import { Delete } from "@mui/icons-material"; import { useFieldArray, useWatch } from "react-hook-form"; -import { ObservedPropertyTypeLU } from "../../../interfaces"; -import { useGetPropertyTypes } from "../../../service/ApiServiceNew"; -import { ControlledSelectNonObject } from "../../../components/RHControlled/ControlledSelect"; -import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; +import { ObservedPropertyTypeLU } from "@/interfaces"; +import { useGetPropertyTypes } from "@/service/ApiServiceNew"; +import { + ControlledSelectNonObject, + ControlledTimepicker, + ControlledTextbox, +} from "@/components"; import dayjs from "dayjs"; const ObservationRow = ({ diff --git a/frontend/src/views/Activities/index.ts b/frontend/src/views/Activities/index.ts index 5e3365f9..b9f1c2f5 100644 --- a/frontend/src/views/Activities/index.ts +++ b/frontend/src/views/Activities/index.ts @@ -1 +1,2 @@ -export * from './ActivitiesView' +export * from "./ActivitiesView"; +export * from "./ActivityPhotoView"; diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index f9aba705..ee18d103 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -1,12 +1,9 @@ -import { useForm, SubmitHandler } from "react-hook-form"; import { useEffect, useState } from "react"; +import { useForm, SubmitHandler } from "react-hook-form"; import { enqueueSnackbar } from "notistack"; import { useAuthUser } from "react-auth-kit"; import { createSearchParams, useNavigate } from "react-router-dom"; -import GradingIcon from "@mui/icons-material/Grading"; -import AddIcon from "@mui/icons-material/Add"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; +import { Add, Grading, Save, SaveAs } from "@mui/icons-material"; import { Button, Grid, Card, CardContent, InputAdornment } from "@mui/material"; import { Table, @@ -16,7 +13,7 @@ import { TableHead, TableRow, } from "@mui/material"; -import { SecurityScope, Meter } from "../../interfaces"; +import { SecurityScope, Meter } from "@/interfaces"; import { useCreateMeter, useGetMeter, @@ -24,13 +21,15 @@ import { } from "../../service/ApiServiceNew"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import ControlledMeterTypeSelect from "../../components/RHControlled/ControlledMeterTypeSelect"; -import ControlledWellSelection from "../../components/RHControlled/ControlledWellSelection"; -import ControlledMeterStatusTypeSelect from "../../components/RHControlled/ControlledMeterStatusTypeSelect"; -import { formatLatLong } from "../../conversions"; -import ControlledMeterRegisterSelect from "../../components/RHControlled/ControlledMeterRegisterSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { + CustomCardHeader, + ControlledTextbox, + ControlledMeterTypeSelect, + ControlledWellSelection, + ControlledMeterStatusTypeSelect, + ControlledMeterRegisterSelect, +} from "@/components"; +import { formatLatLong } from "@/conversions"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), @@ -124,7 +123,7 @@ export const MeterDetailsFields = ({ @@ -292,7 +291,7 @@ export const MeterDetailsFields = ({ variant="contained" onClick={handleSubmit(onAddMeter, onErr)} > - +   Save New Meter ) : ( @@ -301,7 +300,7 @@ export const MeterDetailsFields = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx index 821a685f..a355cd22 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx @@ -2,32 +2,28 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; -import SaveIcon from "@mui/icons-material/Save"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Save, InfoOutlined } from "@mui/icons-material"; import { PatchActivityForm, PatchActivitySubmit, SecurityScope, -} from "../../../interfaces"; -import { - useUpdateActivity, - useDeleteActivity, -} from "../../../service/ApiServiceNew"; +} from "@/interfaces"; +import { useUpdateActivity, useDeleteActivity } from "@/service/ApiServiceNew"; import dayjs from "dayjs"; import { enqueueSnackbar } from "notistack"; - -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledTimepicker from "../../../components/RHControlled/ControlledTimepicker"; -import ControlledActivitySelect from "../../../components/RHControlled/ControlledActivitySelect"; -import ControlledUserSelect from "../../../components/RHControlled/ControlledUserSelect"; -import ControlledWellSelection from "../../../components/RHControlled/ControlledWellSelection"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; - -import NotesChipSelect from "../../../components/RHControlled/NotesChipSelect"; -import ServicesChipSelect from "../../../components/RHControlled/ServicesChipSelect"; -import PartsChipSelect from "../../../components/RHControlled/PartsChipSelect"; -import ControlledCheckbox from "../../../components/RHControlled/ControlledCheckbox"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; +import { + ControlledDatepicker, + ControlledTimepicker, + ControlledActivitySelect, + ControlledUserSelect, + ControlledWellSelection, + ControlledTextbox, + NotesChipSelect, + ServicesChipSelect, + PartsChipSelect, + ControlledCheckbox, + CustomCardHeader, +} from "@/components"; export const SelectedActivityDetails = ({ selectedActivity, @@ -120,15 +116,12 @@ export const SelectedActivityDetails = ({ - + - + @@ -214,7 +204,7 @@ export const SelectedActivityDetails = ({ onClick={handleSubmit(onSaveChanges)} disabled={!hasAdminScope} > - +   Save Changes ) : ( @@ -152,7 +151,7 @@ export const MeterTypeDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index e0505ee6..f29e04a4 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -27,10 +27,12 @@ import { useGetPart, useUpdatePart, } from "@/service/ApiServiceNew"; -import ControlledTextbox from "@/components/RHControlled/ControlledTextbox"; -import ControlledPartTypeSelect from "@/components/RHControlled/ControlledPartTypeSelect"; -import { ControlledSelectNonObject } from "@/components/RHControlled/ControlledSelect"; -import { CustomCardHeader } from "@/components"; +import { + ControlledTextbox, + ControlledPartTypeSelect, + ControlledSelectNonObject, + CustomCardHeader, +} from "@/components"; import { MeterTypeLU, Part } from "@/interfaces"; const PartResolverSchema: Yup.ObjectSchema = Yup.object().shape({ diff --git a/frontend/src/views/Reports/Board/index.tsx b/frontend/src/views/Reports/Board/index.tsx deleted file mode 100644 index c7932f33..00000000 --- a/frontend/src/views/Reports/Board/index.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { ArrowBack, People, PictureAsPdf } from "@mui/icons-material"; -import { - Box, - Button, - Card, - CardContent, - CardHeader, - Grid, - IconButton, - Tooltip, -} from "@mui/material"; -import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { useForm } from "react-hook-form"; -import * as yup from "yup"; -import { yupResolver } from "@hookform/resolvers/yup"; -import dayjs from "dayjs"; - -const schema = yup.object().shape({ - from: yup.mixed().nullable().required("From date is required"), - to: yup.mixed().nullable().required("To date is required"), -}); - -const defaultSchema = { - from: dayjs(), - to: dayjs(), -}; - -export const BoardReportView = () => { - const { control, reset } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultSchema, - }); - - return ( - - - - Board Report - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index eeff2111..6bc80848 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -17,20 +17,34 @@ import { Divider, Box, } from "@mui/material"; -import { LayersControl, MapContainer, Marker, Tooltip as MapTooltip } from "react-leaflet"; +import { + LayersControl, + MapContainer, + Marker, + Tooltip as MapTooltip, +} from "react-leaflet"; import { Link } from "react-router-dom"; import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import L from "leaflet"; -import { API_URL } from "../../../config"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer, SatelliteLayer, OpenStreetMapLayer, WellMapLegend } from "../../../components"; -import { useFetchWithAuth } from "../../../hooks"; -import { useGetWellLocations } from "../../../service/ApiServiceNew"; -import { Well } from "../../../interfaces"; -import { RedMapIcon, BlackMapIcon } from "../../../components/MapIcons"; -import { WellStatus } from "../../../enums"; + +import { API_URL } from "@/config"; +import { + ControlledDatepicker, + CustomCardHeader, + BackgroundBox, + DirectionCard, + SoutheastGuideLayer, + SatelliteLayer, + OpenStreetMapLayer, + WellMapLegend, +} from "@/components"; +import { RedMapIcon, BlackMapIcon } from "@/components/MapIcons"; +import { useFetchWithAuth } from "@/hooks"; +import { useGetWellLocations } from "@/service/ApiServiceNew"; +import { Well } from "@/interfaces"; +import { WellStatus } from "@/enums"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; @@ -43,15 +57,15 @@ const schema = yup.object().shape({ .mixed() .nullable() .required("To date is required") - .test("is-after", "'To' date must be after 'From'", function(value) { + .test("is-after", "'To' date must be after 'From'", function (value) { const { from } = this.parent; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), }); const defaultSchema = { - from: dayjs().startOf('month'), - to: dayjs().endOf('month'), + from: dayjs().startOf("month"), + to: dayjs().endOf("month"), }; interface iMinMaxAvgMedCount { @@ -92,19 +106,13 @@ export const ChloridesReportView = () => { return fetchWithAuth({ method: "GET", route: `/chlorides/report?${searchParams.toString()}`, - }) + }); }, enabled: !!from && !!to, }); const downloadPDFMutation = useMutation({ - mutationFn: async ({ - from, - to, - }: { - from: Dayjs; - to: Dayjs; - }) => { + mutationFn: async ({ from, to }: { from: Dayjs; to: Dayjs }) => { const params = new URLSearchParams({ from_date: from?.format("YYYY-MM-DD"), to_date: to?.format("YYYY-MM-DD"), @@ -140,7 +148,7 @@ export const ChloridesReportView = () => { }); }; - const wellQuery = useGetWellLocations('', true); + const wellQuery = useGetWellLocations("", true); useEffect(() => { if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { @@ -153,10 +161,7 @@ export const ChloridesReportView = () => { return ( - + { sx={{ py: 3, px: 2 }} > - Chlorides Reading: + + Chlorides Reading: + {chloridesQuery.isLoading && ( {[0, 1, 2, 3].map((i) => ( - + - + @@ -247,7 +261,8 @@ export const ChloridesReportView = () => { )} {chloridesQuery.isError && ( - {chloridesQuery.error?.message || "Failed to load chloride readings."} + {chloridesQuery.error?.message || + "Failed to load chloride readings."} )} {!chloridesQuery.isLoading && !chloridesQuery.isError && ( @@ -296,17 +311,19 @@ export const ChloridesReportView = () => { )} - + @@ -350,7 +367,9 @@ export const ChloridesReportView = () => { well.location?.longitude, ]} icon={ - well.well_status_id === WellStatus.PLUGGED ? BlackMapIcon : RedMapIcon + well.well_status_id === WellStatus.PLUGGED + ? BlackMapIcon + : RedMapIcon } > @@ -367,27 +386,41 @@ export const ChloridesReportView = () => { {/* Loading first page */} {wellQuery.isLoading && ( - Loading well markers... + + Loading well markers... + )} {/* Loading additional pages */} {wellQuery.isFetchingNextPage && ( - Loading more wells... + + Loading more wells... + )} {wellQuery.isSuccess && wellMarkers.length === 0 && ( - + No wells found for that search. @@ -395,10 +428,14 @@ export const ChloridesReportView = () => { {/* Error */} {wellQuery.isError && ( - + Failed to load wells: {wellQuery.error.message} @@ -412,6 +449,6 @@ export const ChloridesReportView = () => { - + ); }; diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index a3fa4d18..4cf1441d 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useAuthHeader } from "react-auth-kit"; import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; import { Box, @@ -13,18 +14,11 @@ import { Typography, } from "@mui/material"; import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import ControlledAutocomplete from "../../../components/RHControlled/ControlledAutocomplete"; import { useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; -import { BackgroundBox } from "../../../components/BackgroundBox"; -import ControlledTextbox from "../../../components/RHControlled/ControlledTextbox"; -import { useAuthHeader } from "react-auth-kit"; -import { API_URL } from "../../../config"; import { PieChart } from "@mui/x-charts"; import { DataGrid, @@ -32,6 +26,14 @@ import { GridValueGetter, GridValueFormatter, } from "@mui/x-data-grid"; +import { + ControlledDatepicker, + ControlledAutocomplete, + BackgroundBox, + ControlledTextbox, + CustomCardHeader, +} from "@/components"; +import { API_URL } from "@/config"; interface User { full_name: string; diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 0f92a639..c03bef5b 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -34,9 +34,12 @@ import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; -import { BackgroundBox, CustomCardHeader } from "@/components"; -import ControlledDatepicker from "@/components/RHControlled/ControlledDatepicker"; -import ControlledAutocomplete from "@/components/RHControlled/ControlledAutocomplete"; +import { + ControlledDatepicker, + ControlledAutocomplete, + BackgroundBox, + CustomCardHeader, +} from "@/components"; import { MonitoredWell, WellMeasurementDTO } from "@/interfaces"; import { ReportAveragesResponse } from "@/interfaces/ReportAveragesResponse"; import { useFetchWithAuth } from "@/hooks"; diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index d8b1afdf..369a0035 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo } from "react"; +import { useAuthHeader } from "react-auth-kit"; import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; import { Autocomplete, @@ -13,18 +14,20 @@ import { Tooltip, } from "@mui/material"; import { Link } from "react-router-dom"; -import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; -import dayjs, { Dayjs } from "dayjs"; -import { API_URL } from "../../../config"; -import { useAuthHeader } from "react-auth-kit"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { BackgroundBox } from "../../../components/BackgroundBox"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; -import { ControlledSelect } from "../../../components/RHControlled/ControlledSelect"; +import dayjs, { Dayjs } from "dayjs"; + +import { API_URL } from "@/config"; +import { + ControlledDatepicker, + BackgroundBox, + CustomCardHeader, + ControlledSelect, +} from "@/components"; export interface MeterType { id: number; @@ -63,7 +66,7 @@ const schema = yup.object().shape({ .mixed() .nullable() .required("To date is required") - .test("is-after", "'To' date must be after 'From'", function(value) { + .test("is-after", "'To' date must be after 'From'", function (value) { const { from } = this.parent; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), @@ -87,15 +90,15 @@ const schema = yup.object().shape({ .array() .of(yup.number().required()) .min(1, "At least one Part is required"), - in_use: yup.bool().required() + in_use: yup.bool().required(), }); const defaultSchema = { - from: dayjs().startOf('month'), - to: dayjs().endOf('month'), + from: dayjs().startOf("month"), + to: dayjs().endOf("month"), part_types: [], parts: [], - in_use: true + in_use: true, }; export const PartsUsedReportView = () => { @@ -400,7 +403,7 @@ export const PartsUsedReportView = () => { }} /> - + { - const techiciansQuery = useQuery({ - queryKey: ["workorders", "report", "techicians"], - queryFn: async () => {}, - }); - const sourceQuery = useQuery({ - queryKey: ["workorders", "report", "source"], - queryFn: async () => {}, - }); - - const { control, reset } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultSchema, - }); - - return ( - - - - Work Orders Report - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - { - if (techiciansQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} - /> - - - { - if (sourceQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} - /> - - - - - - - - - - - - ); -}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index a479c2e2..f2387922 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -6,9 +6,7 @@ import { Science, } from "@mui/icons-material"; import { Box, Card, CardContent } from "@mui/material"; -import { NavLink } from "../../components/NavLink"; -import { BackgroundBox } from "../../components/BackgroundBox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { BackgroundBox, CustomCardHeader, NavLink } from "@/components"; export const ReportsView = () => { return ( @@ -17,14 +15,6 @@ export const ReportsView = () => { - {/* - - */} { label="Parts Used" icon={Build} /> - {/* - - */} = Yup.object().shape({ name: Yup.string().required("Please enter a name."), @@ -113,7 +108,7 @@ export const RoleDetailsCard = ({ @@ -148,7 +143,7 @@ export const RoleDetailsCard = ({ label={value.scope_string} clickable deleteIcon={ - event.stopPropagation() } @@ -189,7 +184,7 @@ export const RoleDetailsCard = ({ variant="contained" onClick={handleSubmit(onAddPart, onErr)} > - +   Save New Role ) : ( @@ -198,7 +193,7 @@ export const RoleDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 1bcf638d..33a67a42 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -11,12 +11,14 @@ import { Grid, Typography, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; -import LockResetIcon from "@mui/icons-material/LockReset"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { + Add, + Edit, + Save, + SaveAs, + LockReset, + ExpandMore, +} from "@mui/icons-material"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; @@ -26,14 +28,14 @@ import { useUpdateUser, useGetRoles, useUpdateUserPassword, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; -import { UpdatedUserPassword, User, UserRole } from "../../interfaces"; +} from "@/service/ApiServiceNew"; import { + ControlledTextbox, ControlledSelect, ControlledSelectNonObject, -} from "../../components/RHControlled/ControlledSelect"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; + CustomCardHeader, +} from "@/components"; +import { UpdatedUserPassword, User, UserRole } from "@/interfaces"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ full_name: Yup.string().required("Please enter a full name."), @@ -50,21 +52,20 @@ const formatSubmission = (user: User) => { formattedUser.user_role_id = user.user_role?.id; delete formattedUser.user_role; return formattedUser; -} +}; const SetNewPasswordAccordion = ({ control, errorMessage, - handleSubmit + handleSubmit, }: any) => { return ( } + expandIcon={} sx={{ m: 0, mx: 2, p: 0, color: "#595959" }} > - {" "} -   +   Set New Password for User @@ -81,7 +82,7 @@ const SetNewPasswordAccordion = ({ @@ -89,7 +90,7 @@ const SetNewPasswordAccordion = ({ ); -} +}; export const UserDetailsCard = ({ selectedUser, @@ -113,11 +114,13 @@ export const UserDetailsCard = ({ const onSuccessfulUpdate = () => enqueueSnackbar("Successfully Updated User!", { variant: "success" }); const onSuccessfulPasswordUpdate = () => - enqueueSnackbar("Successfully Updated User's Password!", { variant: "success" }); + 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); @@ -125,7 +128,8 @@ export const UserDetailsCard = ({ const createUser = useCreateUser(onSuccessfulCreate); const updateUserPassword = useUpdateUserPassword(onSuccessfulPasswordUpdate); - const onSaveChanges = (user: User) => updateUser.mutate(formatSubmission(user)); + const onSaveChanges = (user: User) => + updateUser.mutate(formatSubmission(user)); const onCreateUser = (user: User) => { if (!user.password || user.password.length < 1) { @@ -133,7 +137,7 @@ export const UserDetailsCard = ({ return; } createUser.mutate(formatSubmission(user)); - } + }; const onUpdateUserPassword = ( userId: number, @@ -148,7 +152,7 @@ export const UserDetailsCard = ({ new_password: newPassword, }; updateUserPassword.mutate(updatedUserPassword); - } + }; useEffect(() => { if (selectedUser != undefined) { @@ -169,7 +173,7 @@ export const UserDetailsCard = ({ @@ -262,7 +266,7 @@ export const UserDetailsCard = ({ variant="contained" onClick={handleSubmit(onCreateUser, onErr)} > - +   Save New User ) : ( @@ -271,7 +275,7 @@ export const UserDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index 6175a3b5..a8c3b54f 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useQueryClient } from "react-query"; import { @@ -11,10 +11,8 @@ import { Grid, Stack, } from "@mui/material"; -import AddIcon from "@mui/icons-material/Add"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import SaveAsIcon from "@mui/icons-material/SaveAs"; +import { Add, Edit, Save, SaveAs } from "@mui/icons-material"; +import { useAuthUser } from "react-auth-kit"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; @@ -25,8 +23,7 @@ import { useGetWaterSources, useGetWellStatusTypes, useUpdateWell, -} from "../../service/ApiServiceNew"; -import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; +} from "@/service/ApiServiceNew"; import { SubmitWellCreate, WellUpdate, @@ -34,15 +31,17 @@ import { Well, WellStatus, WellUseLU, -} from "../../interfaces"; -import { ControlledSelect } from "../../components/RHControlled/ControlledSelect"; -import ControlledDMS from "../../components/RHControlled/ControlledDMS"; -import { GCSdimension } from "../../enums"; -import { MergeWellModal } from "../../components/MergeWellModal"; -import { useAuthUser } from "react-auth-kit"; -import { SecurityScope } from "../../interfaces"; -import ControlledCheckbox from "../../components/RHControlled/ControlledCheckbox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; + SecurityScope, +} from "@/interfaces"; +import { + ControlledTextbox, + ControlledSelect, + ControlledDMS, + MergeWellModal, + ControlledCheckbox, + CustomCardHeader, +} from "@/components"; +import { GCSdimension } from "@/enums"; const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ use_type: Yup.object().required("Please select a use type."), @@ -124,7 +123,7 @@ export const WellDetailsCard = ({ const hasErrors = () => Object.keys(errors).length > 0; // Modal related functions - const [isWellMergeModalOpen, setIsWellMergeModalOpen] = React.useState(false); + const [isWellMergeModalOpen, setIsWellMergeModalOpen] = useState(false); const handleOpenMergeModal = () => setIsWellMergeModalOpen(true); const handleCloseMergeModal = () => setIsWellMergeModalOpen(false); @@ -132,7 +131,7 @@ export const WellDetailsCard = ({ @@ -315,7 +314,7 @@ export const WellDetailsCard = ({ variant="contained" onClick={handleSubmit(onAddWell, onErr)} > - +   Save New Well ) : ( @@ -324,7 +323,7 @@ export const WellDetailsCard = ({ variant="contained" onClick={handleSubmit(onSaveChanges, onErr)} > - +   Save Changes )} diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index 9927a275..3da88baf 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -1,11 +1,12 @@ -import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; +import { Grid } from "@mui/material"; +import { BackgroundBox } from "@/components"; +import { Well } from "@/interfaces"; + import { WellsTable } from "./WellsTable"; -import { Well } from "../../interfaces"; import { WellDetailsCard } from "./WellDetailsCard"; -import { BackgroundBox } from "../../components/BackgroundBox"; -export default function WellManagementView() { +export const WellManagementView = () => { const [wellAddMode, setWellAddMode] = useState(true); const [selectedWell, setSelectedWell] = useState(); @@ -15,10 +16,7 @@ export default function WellManagementView() { return ( - + ); -} +}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx index c3ad9394..af89586f 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersView.tsx @@ -1,16 +1,16 @@ import { Card, CardContent } from "@mui/material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { BackgroundBox, CustomCardHeader } from "@/components"; + import WorkOrdersTable from "./WorkOrdersTable"; -import { BackgroundBox } from "../../components/BackgroundBox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function WorkOrdersView() { +export const WorkOrdersView = () => { return ( - + @@ -18,4 +18,4 @@ export default function WorkOrdersView() { ); -} +}; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index ae5a45e3..1862084b 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -1,15 +1,15 @@ -export * from './Activities' -export * from './Backups' -export * from './Chlorides' -export * from './Home.ts' -export * from './InsufficientPermView.ts' -export * from './Login.ts' -export * from './Meters' -export * from './MonitoringWells' -export * from './NotFound.ts' -export * from './Parts' -export * from './Reports' -export * from './Settings.ts' -export * from './UserManagement' -export * from './WellManagement' -export * from './WorkOrders' +export * from "./Activities"; +export * from "./Backups"; +export * from "./Chlorides"; +export * from "./Home"; +export * from "./InsufficientPermView"; +export * from "./Login"; +export * from "./Meters"; +export * from "./MonitoringWells"; +export * from "./NotFound"; +export * from "./Parts"; +export * from "./Reports"; +export * from "./Settings"; +export * from "./UserManagement"; +export * from "./WellManagement"; +export * from "./WorkOrders"; From dc120046f1d40d600ea034a5fc41bdcf2df69607 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 9 Feb 2026 22:37:34 -0600 Subject: [PATCH 20/91] chore(components): Update imports --- frontend/src/components/ChipSelect.tsx | 4 +- frontend/src/components/ImageDialog.tsx | 4 +- frontend/src/components/ImagePreviewGrid.tsx | 110 +++++++++-------- frontend/src/components/MergeWellModal.tsx | 5 +- .../src/components/MeterMapColorLegend.tsx | 7 +- .../src/components/MeterRegisterSelect.tsx | 4 +- frontend/src/components/MeterSelection.tsx | 6 +- frontend/src/components/MeterTypeSelect.tsx | 4 +- .../Modals/MonitoredWell/Create.tsx | 2 +- .../Modals/MonitoredWell/Update.tsx | 2 +- .../src/components/Modals/Region/Create.tsx | 2 +- .../src/components/Modals/Region/Update.tsx | 2 +- frontend/src/components/NavLink.tsx | 15 ++- .../RHControlled/ControlledActivitySelect.tsx | 2 +- .../RHControlled/ControlledMeterSelection.tsx | 2 +- .../ControlledMeterStatusTypeSelect.tsx | 2 +- .../ControlledMeterTypeSelect.tsx | 2 +- .../RHControlled/ControlledPartTypeSelect.tsx | 2 +- .../RHControlled/ControlledUserSelect.tsx | 2 +- .../RHControlled/ControlledWellSelection.tsx | 2 +- .../components/RHControlled/NSPChipSelect.tsx | 9 +- .../RHControlled/NotesChipSelect.tsx | 7 +- .../RHControlled/PartsChipSelect.tsx | 2 +- .../RHControlled/ServicesChipSelect.tsx | 2 +- frontend/src/components/ReportsNavItem.tsx | 17 +-- frontend/src/components/StatCell.tsx | 19 ++- frontend/src/components/TopbarUserButton.tsx | 28 +++-- frontend/src/components/UserSelection.tsx | 4 +- frontend/src/components/WellSelection.tsx | 4 +- frontend/src/components/WorkOrderSelect.tsx | 114 +++++++++++------- frontend/src/service/index.ts | 1 + 31 files changed, 220 insertions(+), 168 deletions(-) create mode 100644 frontend/src/service/index.ts diff --git a/frontend/src/components/ChipSelect.tsx b/frontend/src/components/ChipSelect.tsx index 4cf0bbfb..39b656e9 100644 --- a/frontend/src/components/ChipSelect.tsx +++ b/frontend/src/components/ChipSelect.tsx @@ -7,7 +7,7 @@ import { OutlinedInput, Select, } from "@mui/material"; -import CancelIcon from "@mui/icons-material/Cancel"; +import { Cancel } from "@mui/icons-material"; interface chipselectitem { id: number; @@ -45,7 +45,7 @@ export default function ChipSelect({ label={value.name} clickable deleteIcon={ - event.stopPropagation()} /> } diff --git a/frontend/src/components/ImageDialog.tsx b/frontend/src/components/ImageDialog.tsx index 11e4f2e3..7004534d 100644 --- a/frontend/src/components/ImageDialog.tsx +++ b/frontend/src/components/ImageDialog.tsx @@ -1,5 +1,5 @@ import { Dialog, DialogContent, IconButton, Box } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; +import { Close } from "@mui/icons-material"; export const ImageDialog = ({ open, @@ -25,7 +25,7 @@ export const ImageDialog = ({ "&:hover": { backgroundColor: "rgba(0,0,0,0.8)" }, }} > - + {src && ( void; - onOpen?: (src: string) => void; -}) => { - return ( - - {previews.map((src, i) => { - return ( - onOpen?.(src)} - > +export const ImagePreviewGrid = memo( + ({ + previews, + onRemove, + onOpen, + }: { + previews: string[]; + onRemove?: (index: number) => void; + onOpen?: (src: string) => void; + }) => { + return ( + + {previews.map((src, i) => { + return ( - {onRemove && ( - onRemove(i)} + onDoubleClick={() => onOpen?.(src)} + > + - - - )} - - ); - })} - - ); -}); + /> + {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/MergeWellModal.tsx b/frontend/src/components/MergeWellModal.tsx index bc7bbfdc..0afaf310 100644 --- a/frontend/src/components/MergeWellModal.tsx +++ b/frontend/src/components/MergeWellModal.tsx @@ -1,8 +1,9 @@ import { Box, Modal, Button, Grid } from "@mui/material"; import { useState, useEffect } from "react"; -import { useMergeWells } from "../service/ApiServiceNew"; +import { useMergeWells } from "@/service"; +import { Well } from "@/interfaces"; + import WellSelection from "./WellSelection"; -import { Well } from "../interfaces"; export function MergeWellModal({ isWellMergeModalOpen, diff --git a/frontend/src/components/MeterMapColorLegend.tsx b/frontend/src/components/MeterMapColorLegend.tsx index fad8515b..ef75b7d3 100644 --- a/frontend/src/components/MeterMapColorLegend.tsx +++ b/frontend/src/components/MeterMapColorLegend.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useLeafletContext } from "@react-leaflet/core"; import L from "leaflet"; -import { PM_COLORS } from "../constants"; +import { PM_COLORS } from "@/constants"; export const MeterMapColorLegend = () => { const context = useLeafletContext(); @@ -9,7 +9,7 @@ export const MeterMapColorLegend = () => { useEffect(() => { const legend = new L.Control({ position: "bottomleft" }); - legend.onAdd = function() { + legend.onAdd = function () { const div = L.DomUtil.create("div", "info legend"); div.style.background = "white"; @@ -53,5 +53,4 @@ export const MeterMapColorLegend = () => { }, [context.map]); return null; -} - +}; diff --git a/frontend/src/components/MeterRegisterSelect.tsx b/frontend/src/components/MeterRegisterSelect.tsx index 3c1255d6..f9d2b803 100644 --- a/frontend/src/components/MeterRegisterSelect.tsx +++ b/frontend/src/components/MeterRegisterSelect.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo } from "react"; -import { useGetMeterRegisterList } from "../service/ApiServiceNew"; import { FormControl, InputLabel, @@ -7,7 +6,8 @@ import { Select, FormHelperText, } from "@mui/material"; -import { MeterRegister, MeterType } from "../interfaces"; +import { useGetMeterRegisterList } from "@/service"; +import { MeterRegister, MeterType } from "@/interfaces"; function getRegisterTitle(register: MeterRegister) { //Describing the register can be a bit complex, so this function will return a string that describes the register diff --git a/frontend/src/components/MeterSelection.tsx b/frontend/src/components/MeterSelection.tsx index 9df45ae4..0d5136b8 100644 --- a/frontend/src/components/MeterSelection.tsx +++ b/frontend/src/components/MeterSelection.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { Autocomplete, TextField } from "@mui/material"; -import { useGetMeterList } from "../service/ApiServiceNew"; import { useDebounce } from "use-debounce"; -import { MeterListDTO } from "../interfaces"; -import { MeterStatusNames } from "../enums"; +import { useGetMeterList } from "@/service"; +import { MeterListDTO } from "@/interfaces"; +import { MeterStatusNames } from "@/enums"; interface MeterSelectionProps { selectedMeter: MeterListDTO | undefined; diff --git a/frontend/src/components/MeterTypeSelect.tsx b/frontend/src/components/MeterTypeSelect.tsx index 05246ac1..d2be1e64 100644 --- a/frontend/src/components/MeterTypeSelect.tsx +++ b/frontend/src/components/MeterTypeSelect.tsx @@ -1,6 +1,6 @@ -import { useGetMeterTypeList } from "../service/ApiServiceNew"; import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; -import { MeterTypeLU } from "../interfaces"; +import { useGetMeterTypeList } from "@/service"; +import { MeterTypeLU } from "@/interfaces"; export default function MeterTypeSelect({ selectedMeterTypeID, diff --git a/frontend/src/components/Modals/MonitoredWell/Create.tsx b/frontend/src/components/Modals/MonitoredWell/Create.tsx index 6510f277..cca993f8 100644 --- a/frontend/src/components/Modals/MonitoredWell/Create.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Create.tsx @@ -22,7 +22,7 @@ dayjs.extend(timezone); import { DatePicker, TimePicker } from "@mui/x-date-pickers"; import { NewWellMeasurement, SecurityScope } from "@/interfaces"; -import { useGetUserList } from "@/service/ApiServiceNew"; +import { useGetUserList } from "@/service"; import { Save } from "@mui/icons-material"; export const CreateModal = ({ diff --git a/frontend/src/components/Modals/MonitoredWell/Update.tsx b/frontend/src/components/Modals/MonitoredWell/Update.tsx index 0a700c3a..5f4c7874 100644 --- a/frontend/src/components/Modals/MonitoredWell/Update.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Update.tsx @@ -20,7 +20,7 @@ dayjs.extend(utc); dayjs.extend(timezone); import { DatePicker, TimePicker } from "@mui/x-date-pickers"; -import { useGetUserList } from "@/service/ApiServiceNew"; +import { useGetUserList } from "@/service"; import { PatchWellMeasurement } from "@/interfaces"; export function UpdateModal({ diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index 7ef6942b..5e5744ff 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -30,7 +30,7 @@ import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -import { useGetUserList } from "@/service/ApiServiceNew"; +import { useGetUserList } from "@/service"; import { useFetchWithAuth } from "@/hooks"; export const CreateModal = ({ diff --git a/frontend/src/components/Modals/Region/Update.tsx b/frontend/src/components/Modals/Region/Update.tsx index 86357e74..64ab5d91 100644 --- a/frontend/src/components/Modals/Region/Update.tsx +++ b/frontend/src/components/Modals/Region/Update.tsx @@ -28,7 +28,7 @@ import { Delete, Save, } from "@mui/icons-material"; -import { useGetUserList } from "@/service/ApiServiceNew"; +import { useGetUserList } from "@/service"; import { useQuery } from "react-query"; import { useFetchWithAuth } from "@/hooks"; import { MonitoredWell, PatchRegionMeasurement } from "@/interfaces"; diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index f6541404..40d14ab8 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -1,7 +1,14 @@ -import { SvgIconProps, Badge, ListItem, ListItemButton, ListItemIcon, ListItemText } from "@mui/material"; -import TableViewIcon from "@mui/icons-material/TableView"; +import { + SvgIconProps, + Badge, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, +} from "@mui/material"; +import { TableView } from "@mui/icons-material"; import { Link, type LinkProps } from "react-router-dom"; -import { useIsActiveRoute } from "../hooks"; +import { useIsActiveRoute } from "@/hooks"; export const NavLink = ({ disabled = false, @@ -46,7 +53,7 @@ export const NavLink = ({ ) : ( - + )} { const notesList = useGetNoteTypes(); diff --git a/frontend/src/components/RHControlled/PartsChipSelect.tsx b/frontend/src/components/RHControlled/PartsChipSelect.tsx index 5537cc10..9a9751f6 100644 --- a/frontend/src/components/RHControlled/PartsChipSelect.tsx +++ b/frontend/src/components/RHControlled/PartsChipSelect.tsx @@ -1,7 +1,7 @@ import { Controller } from "react-hook-form"; import { Part } from "@/interfaces"; -import { useGetMeterPartsList } from "@/service/ApiServiceNew"; +import { useGetMeterPartsList } from "@/service"; import ChipSelect from "../ChipSelect"; diff --git a/frontend/src/components/RHControlled/ServicesChipSelect.tsx b/frontend/src/components/RHControlled/ServicesChipSelect.tsx index 7bb4e5f0..c3010108 100644 --- a/frontend/src/components/RHControlled/ServicesChipSelect.tsx +++ b/frontend/src/components/RHControlled/ServicesChipSelect.tsx @@ -1,7 +1,7 @@ import { Controller } from "react-hook-form"; import { ServiceTypeLU } from "@/interfaces"; -import { useGetServiceTypes } from "@/service/ApiServiceNew"; +import { useGetServiceTypes } from "@/service"; import ChipSelect from "../ChipSelect"; diff --git a/frontend/src/components/ReportsNavItem.tsx b/frontend/src/components/ReportsNavItem.tsx index 2de81665..28ac2a88 100644 --- a/frontend/src/components/ReportsNavItem.tsx +++ b/frontend/src/components/ReportsNavItem.tsx @@ -5,15 +5,17 @@ import { ListItemIcon, ListItemText, } from "@mui/material"; -import { - Assessment, - ExpandLess, - ExpandMore, -} from "@mui/icons-material"; +import { Assessment, ExpandLess, ExpandMore } from "@mui/icons-material"; import { useNavigate } from "react-router-dom"; -import { useIsActiveRoute } from "../hooks"; +import { useIsActiveRoute } from "@/hooks"; -export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Dispatch> }) { +export function ReportsNavItem({ + open, + setOpen, +}: { + open: boolean; + setOpen: Dispatch>; +}) { const navigate = useNavigate(); const [clickTimer, setClickTimer] = useState(null); const isActive = useIsActiveRoute("/reports"); @@ -69,4 +71,3 @@ export function ReportsNavItem({ open, setOpen }: { open: boolean, setOpen: Disp ); } - diff --git a/frontend/src/components/StatCell.tsx b/frontend/src/components/StatCell.tsx index 954ac962..1a6b2329 100644 --- a/frontend/src/components/StatCell.tsx +++ b/frontend/src/components/StatCell.tsx @@ -1,13 +1,24 @@ import { Stack, Typography } from "@mui/material"; -import { formatNumberData } from "../utils"; +import { formatNumberData } from "@/utils"; -export const StatCell = ({ label, value, isCount }: { label: string; value?: number, isCount?: boolean }) => { +export const StatCell = ({ + label, + value, + isCount, +}: { + label: string; + value?: number; + isCount?: boolean; +}) => { return ( {label} - {formatNumberData(value)}{isCount ? "" : " ppm"} + + {formatNumberData(value)} + {isCount ? "" : " ppm"} + ); -} +}; diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index 5cbd6fab..d0bf7eaa 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -1,8 +1,7 @@ import { Avatar, Button, ButtonProps } from "@mui/material"; -import { getRoleColor } from "../utils"; import { Badge, Engineering, Face } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; - +import { getRoleColor } from "@/utils"; export const TopbarUserButton = ({ display_name, @@ -10,9 +9,9 @@ export const TopbarUserButton = ({ src, ...buttonProps }: { - display_name: string, - role: string, - src?: string + display_name: string; + role: string; + src?: string; } & ButtonProps) => { const theme = useTheme(); const buttonColor = getRoleColor(role); @@ -23,22 +22,25 @@ export const TopbarUserButton = ({ const roleIcons: Record = { Admin: , - Technician: , + Technician: ( + + ), }; - const renderRoleIcon = () => roleIcons[role] ?? ; + const renderRoleIcon = () => + roleIcons[role] ?? ; const roleBgColor: Record = { Admin: primary.dark, Technician: secondary.dark, - OSE: warning.dark - } + OSE: warning.dark, + }; const roleBorderColor: Record = { Admin: primary.contrastText, Technician: secondary.contrastText, - OSE: warning.contrastText - } + OSE: warning.contrastText, + }; return ( ); -} +}; diff --git a/frontend/src/components/UserSelection.tsx b/frontend/src/components/UserSelection.tsx index bf913158..814742ea 100644 --- a/frontend/src/components/UserSelection.tsx +++ b/frontend/src/components/UserSelection.tsx @@ -1,7 +1,7 @@ -import { User } from "../interfaces"; import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; -import { useGetUserList } from "../service/ApiServiceNew"; import { useAuthUser } from "react-auth-kit"; +import { useGetUserList } from "@/service"; +import { User } from "@/interfaces"; export default function UserSelection({ selectedUser, diff --git a/frontend/src/components/WellSelection.tsx b/frontend/src/components/WellSelection.tsx index e81c7880..03debe63 100644 --- a/frontend/src/components/WellSelection.tsx +++ b/frontend/src/components/WellSelection.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { TextField } from "@mui/material"; import { useDebounce } from "use-debounce"; import { Autocomplete } from "@mui/material"; -import { useGetWells } from "../service/ApiServiceNew"; -import { Well } from "../interfaces"; +import { useGetWells } from "@/service"; +import { Well } from "@/interfaces"; export default function WellSelection({ selectedWell, diff --git a/frontend/src/components/WorkOrderSelect.tsx b/frontend/src/components/WorkOrderSelect.tsx index 3eaa7ea2..0e5ad26f 100644 --- a/frontend/src/components/WorkOrderSelect.tsx +++ b/frontend/src/components/WorkOrderSelect.tsx @@ -2,61 +2,83 @@ A simple select component that limits options based on filters. */ -import React, { useEffect } from 'react' -import { FormControl, InputLabel, MenuItem, Select } from '@mui/material' -import { useGetWorkOrders } from '../service/ApiServiceNew' -import { WorkOrderStatus } from '../enums' -import { WorkOrder } from '../interfaces' +import { useEffect, useState } from "react"; +import { FormControl, InputLabel, MenuItem, Select } from "@mui/material"; +import { useGetWorkOrders } from "@/service"; +import { WorkOrderStatus } from "@/enums"; +import { WorkOrder } from "@/interfaces"; interface WorkOrderSelectFilters { - meter_serial?: string - assigned_user_id?: number - date_created?: Date + meter_serial?: string; + assigned_user_id?: number; + date_created?: Date; } interface WorkOrderSelectProps { - selectedWorkOrderID: number | null - setSelectedWorkOrderID: (workOrderID: number | null) => void - option_filters?: WorkOrderSelectFilters + selectedWorkOrderID: number | null; + setSelectedWorkOrderID: (workOrderID: number | null) => void; + option_filters?: WorkOrderSelectFilters; } -function optionsFilter(workOrders: WorkOrder[], filters: WorkOrderSelectFilters) { - //Use the filter method for each component of the filter - if (filters.meter_serial && filters.meter_serial !== undefined) { - workOrders = workOrders.filter((workOrder) => workOrder.meter_serial === filters.meter_serial) - } - if (filters.assigned_user_id && filters.assigned_user_id !== undefined) { - workOrders = workOrders.filter((workOrder) => workOrder.assigned_user_id === filters.assigned_user_id) - } - if (filters.date_created && filters.date_created !== undefined) { - workOrders = workOrders.filter((workOrder) => workOrder.date_created === filters.date_created) - } - return workOrders +function optionsFilter( + workOrders: WorkOrder[], + filters: WorkOrderSelectFilters, +) { + //Use the filter method for each component of the filter + if (filters.meter_serial && filters.meter_serial !== undefined) { + workOrders = workOrders.filter( + (workOrder) => workOrder.meter_serial === filters.meter_serial, + ); + } + if (filters.assigned_user_id && filters.assigned_user_id !== undefined) { + workOrders = workOrders.filter( + (workOrder) => workOrder.assigned_user_id === filters.assigned_user_id, + ); + } + if (filters.date_created && filters.date_created !== undefined) { + workOrders = workOrders.filter( + (workOrder) => workOrder.date_created === filters.date_created, + ); + } + return workOrders; } -export default function WorkOrderSelect({selectedWorkOrderID, setSelectedWorkOrderID, option_filters}: WorkOrderSelectProps) { - const workOrderList = useGetWorkOrders([WorkOrderStatus['Open']]) - const [filteredWorkOrders, setFilteredWorkOrders] = React.useState([]) +export default function WorkOrderSelect({ + selectedWorkOrderID, + setSelectedWorkOrderID, + option_filters, +}: WorkOrderSelectProps) { + const workOrderList = useGetWorkOrders([WorkOrderStatus["Open"]]); + const [filteredWorkOrders, setFilteredWorkOrders] = useState([]); - useEffect(() => { - if (workOrderList.data) { - setFilteredWorkOrders(optionsFilter(workOrderList.data, option_filters ?? {})); - } - }, [workOrderList, option_filters]); + useEffect(() => { + if (workOrderList.data) { + setFilteredWorkOrders( + optionsFilter(workOrderList.data, option_filters ?? {}), + ); + } + }, [workOrderList, option_filters]); - return ( - - Work Order - setSelectedWorkOrderID(event.target.value)} + > + None + {filteredWorkOrders.map((workOrder: WorkOrder) => { + return ( + - None - {filteredWorkOrders.map((workOrder: WorkOrder) => { - return {workOrder.title} - })} - - - ) -} \ No newline at end of file + {workOrder.title} + + ); + })} + + + ); +} diff --git a/frontend/src/service/index.ts b/frontend/src/service/index.ts new file mode 100644 index 00000000..67ffb42e --- /dev/null +++ b/frontend/src/service/index.ts @@ -0,0 +1 @@ +export * from "./ApiServiceNew"; From cb6ba28d698b99b717459a67c98fcbd6c9c02a1a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 10 Feb 2026 01:12:37 -0600 Subject: [PATCH 21/91] fix(Modals): Fix broken types in Create & Update --- frontend/src/AppLayout.tsx | 13 +- .../src/components/Modals/Region/Create.tsx | 48 ++- .../src/components/Modals/Region/Update.tsx | 79 ++-- frontend/src/components/TabPanel.tsx | 48 +-- frontend/src/components/Topbar.tsx | 90 +++-- frontend/src/hooks/useFetchST2.ts | 4 +- frontend/src/hooks/useFetchWithAuth.ts | 6 +- frontend/src/service/ApiServiceNew.ts | 4 +- frontend/src/utils/index.ts | 1 + .../src/views/Activities/ActivitiesView.tsx | 3 +- .../views/Activities/ActivityPhotoView.tsx | 4 +- .../MeterActivityEntry/ActivityFormConfig.ts | 6 +- .../MaintenanceRepairSelection.tsx | 2 +- .../MeterActivityEntry/MeterActivityEntry.tsx | 2 +- .../MeterActivityEntry/NotesSelection.tsx | 13 +- .../ObservationsSelection.tsx | 2 +- .../MeterActivityEntry/PartsSelection.tsx | 6 +- .../src/views/Chlorides/ChloridesView.tsx | 11 +- frontend/src/views/Home.tsx | 35 +- frontend/src/views/Login.tsx | 18 +- .../Meters/MeterHistory/MeterHistory.tsx | 12 +- .../Meters/MeterHistory/MeterHistoryTable.tsx | 6 +- .../Meters/MeterHistory/SelectedBlankCard.tsx | 2 +- .../MeterHistory/SelectedHistoryDetails.tsx | 8 +- .../SelectedObservationDetails.tsx | 2 +- .../Meters/MeterSelection/MeterSelection.tsx | 29 +- .../MeterSelection/MeterSelectionMap.tsx | 73 ++-- .../MonitoringWells/MonitoringWellsView.tsx | 65 ++-- frontend/src/views/NotFound.tsx | 12 +- .../src/views/Parts/MeterTypeDetailsCard.tsx | 5 +- frontend/src/views/Parts/MeterTypesTable.tsx | 2 +- frontend/src/views/Parts/PartDetailsCard.tsx | 3 +- frontend/src/views/Parts/PartsTable.tsx | 2 +- frontend/src/views/Parts/PartsView.tsx | 11 +- frontend/src/views/Settings.tsx | 362 ++++++++++++------ .../views/UserManagement/PermissionsTable.tsx | 2 +- .../views/UserManagement/RoleDetailsCard.tsx | 6 +- .../src/views/UserManagement/RolesTable.tsx | 2 +- .../views/UserManagement/UserDetailsCard.tsx | 2 +- .../UserManagement/UserManagementView.tsx | 7 +- .../src/views/UserManagement/UsersTable.tsx | 2 +- .../views/WellManagement/WellDetailsCard.tsx | 2 +- .../views/WellManagement/WellSelectionMap.tsx | 76 ++-- .../WellManagement/WellSelectionTable.tsx | 2 +- .../src/views/WellManagement/WellsTable.tsx | 34 +- 45 files changed, 696 insertions(+), 428 deletions(-) diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 0a9aaf78..69630725 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -1,19 +1,15 @@ import { useState } from "react"; import { Box } from "@mui/material"; -import Topbar from "./components/Topbar"; +import { Topbar } from "@/components"; import Sidenav from "./sidenav"; const drawerWidth = 250; -export const AppLayout = ({ - children, -}: { - children: JSX.Element -}) => { +export const AppLayout = ({ children }: { children: JSX.Element }) => { const [drawerOpen, setDrawerOpen] = useState(false); return ( - + setDrawerOpen(!drawerOpen)} @@ -35,7 +31,6 @@ export const AppLayout = ({ > {children} - + ); }; - diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index 5e5744ff..2695a2f6 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -22,6 +22,7 @@ import { useQuery } from "react-query"; import { MonitoredWell, NewRegionMeasurement, + NewWellMeasurement, SecurityScope, } from "@/interfaces"; import dayjs, { Dayjs } from "dayjs"; @@ -33,19 +34,26 @@ dayjs.extend(timezone); import { useGetUserList } from "@/service"; import { useFetchWithAuth } from "@/hooks"; -export const CreateModal = ({ - region_id, //Used to filter wells - open, - onClose, - handleSubmitNewMeasurement, - title = "Create New Measurement", -}: { - region_id: number; //Used to filter wells - open: boolean; - onClose: () => void; - handleSubmitNewMeasurement: (newMeasurement: NewRegionMeasurement) => void; - title?: string; -}) => { +type CreateModalProps = + | { + mode: "region"; + region_id?: number; + open: boolean; + onClose: () => void; + handleSubmitNewMeasurement: (m: Partial) => void; + title?: string; + } + | { + mode: "well"; + open: boolean; + onClose: () => void; + handleSubmitNewMeasurement: (m: Partial) => void; + title?: string; + }; + +export const CreateModal = (props: CreateModalProps) => { + const { open, onClose, title = "Create New Measurement" } = props; + const authUser = useAuthUser(); const hasAdminScope = authUser() ?.user_role.security_scopes.map( @@ -54,12 +62,13 @@ export const CreateModal = ({ .includes("admin"); const fetchWithAuth = useFetchWithAuth(); + const regionId = props.mode === "region" ? props.region_id : undefined; const { data: wells, isLoading: isLoadingWells } = useQuery< { items: MonitoredWell[] }, Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups", region_id], + queryKey: ["wells", "has_chloride_groups", regionId], queryFn: () => fetchWithAuth({ method: "GET", @@ -68,11 +77,11 @@ export const CreateModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, - chloride_group_id: region_id, + chloride_group_id: regionId, limit: 100, }, }), - enabled: open, + enabled: open && props.mode === "region" && !!regionId, select: (res) => res.items, }); @@ -95,7 +104,7 @@ export const CreateModal = ({ .minute(selectedTime.minute()) .second(selectedTime.second()); - handleSubmitNewMeasurement({ + props.handleSubmitNewMeasurement({ region_id: 0, // Set by parent well_id: selectedWellID as number, timestamp: combinedDateTime.toISOString(), @@ -239,8 +248,9 @@ export const CreateModal = ({ setValue(newValue === "" ? null : Number(newValue)); }} /> - - + {props.mode === "region" && regionId ? ( + + ) : null} diff --git a/frontend/src/components/Modals/Region/Update.tsx b/frontend/src/components/Modals/Region/Update.tsx index 64ab5d91..1a0bf600 100644 --- a/frontend/src/components/Modals/Region/Update.tsx +++ b/frontend/src/components/Modals/Region/Update.tsx @@ -31,30 +31,52 @@ import { import { useGetUserList } from "@/service"; import { useQuery } from "react-query"; import { useFetchWithAuth } from "@/hooks"; -import { MonitoredWell, PatchRegionMeasurement } from "@/interfaces"; +import { + MonitoredWell, + PatchRegionMeasurement, + PatchWellMeasurement, +} from "@/interfaces"; + +type UpdateModalProps = + | { + mode: "region"; + region_id?: number; + open: boolean; + onClose: () => void; + measurement: Partial; + onUpdateMeasurement: (value: Partial) => void; + onSubmitUpdate: () => void; + onDeleteMeasurement: () => void; + title?: string; + } + | { + mode: "well"; + open: boolean; + onClose: () => void; + measurement: Partial; + onUpdateMeasurement: (value: Partial) => void; + onSubmitUpdate: () => void; + onDeleteMeasurement: () => void; + title?: string; + }; + +export const UpdateModal = (props: UpdateModalProps) => { + const { + open, + onClose, + onSubmitUpdate, + onDeleteMeasurement, + title = "Update Measurement", + } = props; -export const UpdateModal = ({ - region_id, //Used to filter wells - open, - onClose, - measurement, - onUpdateMeasurement, - onSubmitUpdate, - onDeleteMeasurement, - title = "Update Measurement", -}: { - region_id: number; //Used to filter wells - open: boolean; - onClose: () => void; - measurement: PatchRegionMeasurement; - onUpdateMeasurement: (value: Partial) => void; - onSubmitUpdate: () => void; - onDeleteMeasurement: () => void; - title?: string; -}) => { const userList = useGetUserList(); const fetchWithAuth = useFetchWithAuth(); + const regionId = props.mode === "region" ? props.region_id : undefined; + + const measurement = props.measurement as any; // only for local reading convenience + const onUpdateMeasurement = props.onUpdateMeasurement as any; + const [notSampled, setNotSampled] = useState( measurement.value === undefined || measurement.value === null, ); @@ -65,7 +87,7 @@ export const UpdateModal = ({ Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups", region_id], + queryKey: ["wells", "has_chloride_groups", regionId], queryFn: () => fetchWithAuth({ method: "GET", @@ -74,15 +96,18 @@ export const UpdateModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, - chloride_group_id: region_id, + chloride_group_id: regionId, limit: 100, }, }), - enabled: open, + enabled: open && props.mode === "region" && !!regionId, select: (res) => res.items, }); const handleToggleNotSampled = (checked: boolean) => { + // only meaningful in region mode + if (props.mode !== "region") return; + setNotSampled(checked); if (checked) { @@ -98,8 +123,10 @@ export const UpdateModal = ({ }; useEffect(() => { - setNotSampled(measurement.value == null); - }, [measurement.value]); + if (props.mode === "region") { + setNotSampled(measurement.value == null); + } + }, [props.mode, measurement.value]); return ( {wells ?.filter( - (well: MonitoredWell) => well.chloride_group_id === region_id, + (well: MonitoredWell) => well.chloride_group_id === regionId, ) .map((well: MonitoredWell) => ( diff --git a/frontend/src/components/TabPanel.tsx b/frontend/src/components/TabPanel.tsx index 26cae3d7..b869d461 100644 --- a/frontend/src/components/TabPanel.tsx +++ b/frontend/src/components/TabPanel.tsx @@ -1,24 +1,24 @@ -import React from 'react' - -interface TabPanelProps { - children?: React.ReactNode - tabIndex: number - currentTabIndex: number -} - -export default function TabPanel({children, tabIndex, currentTabIndex}: TabPanelProps) { - return ( - - ) -} +import React from "react"; + +interface TabPanelProps { + children?: React.ReactNode; + tabIndex: number; + currentTabIndex: number; +} + +export const TabPanel = ({ + children, + tabIndex, + currentTabIndex, +}: TabPanelProps) => { + return ( + + ); +}; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 13ec25d5..4d19b01d 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, MouseEvent } from "react"; import { AppBar, Toolbar, @@ -18,7 +18,15 @@ import { useAuthUser, useSignOut } from "react-auth-kit"; import { Login, Logout, Settings } from "@mui/icons-material"; import { RoleChip, TopbarUserButton } from "./index"; -export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMenuClick: () => void; sx?: any }) { +export const Topbar = ({ + open, + onMenuClick, + sx, +}: { + open: boolean; + onMenuClick: () => void; + sx?: any; +}) => { const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); @@ -28,7 +36,7 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen const role: string = authUser()?.user_role?.name; const isLoggedIn = !!authUser(); - const handleMenuOpen = (event: React.MouseEvent) => { + const handleMenuOpen = (event: MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -60,7 +68,7 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen > - {!open ? + {!open ? ( Meter Manager - : null} + ) : null} {isLoggedIn ? ( @@ -95,8 +103,8 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose} - transformOrigin={{ horizontal: 'right', vertical: 'top' }} - anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + transformOrigin={{ horizontal: "right", vertical: "top" }} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} > - + Role: @@ -116,8 +128,8 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen { - navigate("/settings") - handleMenuClose() + navigate("/settings"); + handleMenuClose(); }} > @@ -128,8 +140,8 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen { - fullSignOut() - handleMenuClose() + fullSignOut(); + handleMenuClose(); }} > @@ -139,37 +151,35 @@ export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMen - ) - : ( - - )} + + + + )} ); -} - +}; diff --git a/frontend/src/hooks/useFetchST2.ts b/frontend/src/hooks/useFetchST2.ts index a3dd4bf7..e41450fe 100644 --- a/frontend/src/hooks/useFetchST2.ts +++ b/frontend/src/hooks/useFetchST2.ts @@ -1,5 +1,5 @@ -import { formatQueryParams } from "../utils/HttpUtils"; -import { ST2Measurement, ST2Response } from "../interfaces"; +import { formatQueryParams } from "@/utils"; +import { ST2Measurement, ST2Response } from "@/interfaces"; export const useFetchST2 = () => { const ST2_API_BASE_URL = diff --git a/frontend/src/hooks/useFetchWithAuth.ts b/frontend/src/hooks/useFetchWithAuth.ts index 6ac7fbf3..2d76d6e3 100644 --- a/frontend/src/hooks/useFetchWithAuth.ts +++ b/frontend/src/hooks/useFetchWithAuth.ts @@ -1,9 +1,9 @@ import { useAuthHeader, useSignOut } from "react-auth-kit"; import { useNavigate } from "react-router-dom"; -import { formatQueryParams } from "../utils/HttpUtils"; +import { formatQueryParams } from "@/utils"; import { enqueueSnackbar } from "notistack"; -import { HttpStatus } from "../enums"; -import { API_URL } from "../config"; +import { HttpStatus } from "@/enums"; +import { API_URL } from "@/config"; export const useFetchWithAuth = () => { const authHeader = useAuthHeader(); diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index c6abd7f6..de897111 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1372,7 +1372,7 @@ export function useCreateWaterLevel() { const authHeader = useAuthHeader(); return useMutation({ - mutationFn: async (newWaterLevel: NewWellMeasurement) => { + mutationFn: async (newWaterLevel: Partial) => { const response = await POSTFetch(route, newWaterLevel, authHeader()); if (!response.ok) { @@ -1411,7 +1411,7 @@ export function useUpdateWaterLevel(onSuccess: Function) { const authHeader = useAuthHeader(); return useMutation({ - mutationFn: async (updatedWaterLevel: PatchWellMeasurement) => { + mutationFn: async (updatedWaterLevel: Partial) => { const response = await PATCHFetch(route, updatedWaterLevel, authHeader()); if (!response.ok) { diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 23db1355..61944980 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./DateUtils"; +export * from "./DataStreamUtils"; export * from "./EmptyToNull"; export * from "./HttpUtils"; export * from "./GetMeterMarkerColor"; diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index e7b41bce..2e6caf27 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,8 +1,7 @@ import { CardContent, Card } from "@mui/material"; import MeterActivityEntry from "./MeterActivityEntry/MeterActivityEntry"; import { Construction } from "@mui/icons-material"; -import { BackgroundBox } from "../../components/BackgroundBox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; +import { BackgroundBox, CustomCardHeader } from "@/components"; export const ActivitiesView = () => { return ( diff --git a/frontend/src/views/Activities/ActivityPhotoView.tsx b/frontend/src/views/Activities/ActivityPhotoView.tsx index deba9508..2935271c 100644 --- a/frontend/src/views/Activities/ActivityPhotoView.tsx +++ b/frontend/src/views/Activities/ActivityPhotoView.tsx @@ -1,9 +1,9 @@ import { useMemo, useState } from "react"; import { useParams } from "react-router-dom"; -import { API_URL } from "../../config"; import { Card, CardContent, Skeleton, Box, Alert } from "@mui/material"; import { Image } from "@mui/icons-material"; -import { BackgroundBox, CustomCardHeader } from "../../components"; +import { API_URL } from "@/config"; +import { BackgroundBox, CustomCardHeader } from "@/components"; export const ActivityPhotoView = () => { const { activity_id, photo_file_name } = useParams(); diff --git a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts index 72a6d894..39dd1898 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts +++ b/frontend/src/views/Activities/MeterActivityEntry/ActivityFormConfig.ts @@ -1,12 +1,12 @@ import * as Yup from "yup"; +import Dayjs from "dayjs"; +import dayjs from "dayjs"; import { ActivityForm, ActivityFormControl, MeterListDTO, ObservationForm, -} from "../../../interfaces.d"; -import Dayjs from "dayjs"; -import dayjs from "dayjs"; +} from "@/interfaces"; // Form validation, these are applied to the current form when submitting export const ActivityResolverSchema: Yup.ObjectSchema = Yup.object() diff --git a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx index 4457a3ca..42778c5f 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MaintenanceRepairSelection.tsx @@ -1,7 +1,7 @@ import { Box, Grid, Typography } from "@mui/material"; import { useFieldArray } from "react-hook-form"; import { ControlledTextbox, StyledToggleButton } from "@/components"; -import { useGetServiceTypes } from "@/service/ApiServiceNew"; +import { useGetServiceTypes } from "@/service"; export default function MaintenanceRepairSelection({ control, diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx index dc4abc25..fdef0f41 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx @@ -8,7 +8,7 @@ import { useAuthHeader } from "react-auth-kit"; import { yupResolver } from "@hookform/resolvers/yup"; import { ActivityFormControl, MeterListDTO } from "@/interfaces"; import { ActivityType } from "@/enums"; -import { useGetMeter, useGetWell } from "@/service/ApiServiceNew"; +import { useGetMeter, useGetWell } from "@/service"; import { API_URL } from "@/config"; import { MeterActivitySelection } from "./MeterActivitySelection"; import ObservationSelection from "./ObservationsSelection"; diff --git a/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx index 5a66924b..02a95727 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/NotesSelection.tsx @@ -11,10 +11,10 @@ import { } from "@mui/material"; import ToggleButtonGroup from "@mui/material/ToggleButtonGroup"; import { Controller, useFieldArray } from "react-hook-form"; -import { ImageUploadWithPreview, StyledToggleButton } from "../../../components"; -import { NoteTypeLU } from "../../../interfaces"; -import { WorkingOnArrivalValue } from "../../../enums"; -import { useGetNoteTypes } from "../../../service/ApiServiceNew"; +import { ImageUploadWithPreview, StyledToggleButton } from "@/components"; +import { NoteTypeLU } from "@/interfaces"; +import { WorkingOnArrivalValue } from "@/enums"; +import { useGetNoteTypes } from "@/service"; export default function NotesSelection({ control, watch }: any) { const notesList = useGetNoteTypes(); @@ -134,7 +134,10 @@ export default function NotesSelection({ control, watch }: any) { name="photos" control={control} render={({ field }) => ( - field.onChange(files)} /> + field.onChange(files)} + /> )} /> diff --git a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx index 6aa16506..02ddc0da 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/ObservationsSelection.tsx @@ -4,7 +4,7 @@ import { UseQueryResult } from "react-query"; import { Delete } from "@mui/icons-material"; import { useFieldArray, useWatch } from "react-hook-form"; import { ObservedPropertyTypeLU } from "@/interfaces"; -import { useGetPropertyTypes } from "@/service/ApiServiceNew"; +import { useGetPropertyTypes } from "@/service"; import { ControlledSelectNonObject, ControlledTimepicker, diff --git a/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx index b0d65bcd..8c0820d2 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/PartsSelection.tsx @@ -10,9 +10,9 @@ import { Typography, } from "@mui/material"; import { useFieldArray } from "react-hook-form"; -import { Part } from "../../../interfaces"; -import { StyledToggleButton } from "../../../components"; -import { useGetMeterPartsList } from "../../../service/ApiServiceNew"; +import { Part } from "@/interfaces"; +import { StyledToggleButton } from "@/components"; +import { useGetMeterPartsList } from "@/service"; export default function PartsSelection({ control, watch, setValue }: any) { const partsList = useGetMeterPartsList({ diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 1a8d0e78..c8ee5fa6 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -25,10 +25,7 @@ import { RegionMeasurementDTO, } from "@/interfaces"; import { useFetchWithAuth } from "@/hooks"; -import { - BackgroundBox, - CustomCardHeader -} from "@/components"; +import { BackgroundBox, CustomCardHeader } from "@/components"; import { emptyToNull } from "@/utils"; import { ChloridesTable } from "./ChloridesTable"; import { ChloridesPlot } from "./ChloridesPlot"; @@ -91,7 +88,7 @@ export const ChloridesView = () => { const milligramPerLiterUnitId = 14; const { mutateAsync: createChlorideLevel } = useMutation({ mutationKey: ["regions", "creation"], - mutationFn: (body: NewRegionMeasurement) => + mutationFn: (body: Partial) => fetchWithAuth({ method: "POST", route: "/chlorides", @@ -175,7 +172,7 @@ export const ChloridesView = () => { const error = errorRegions || errorManual; - const handleSubmitNewMeasurement = (data: NewRegionMeasurement) => { + const handleSubmitNewMeasurement = (data: Partial) => { if (regionId) { data.region_id = regionId; createChlorideLevel(data, { onSuccess: () => refetchManual() }); @@ -299,12 +296,14 @@ export const ChloridesView = () => { {authUser() && ( <> setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx index 29016cc7..a1e28c33 100644 --- a/frontend/src/views/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -1,10 +1,19 @@ -import { Grid, Card, CardContent, CardMedia, List, ListItem, ListItemText, Stack, Typography } from "@mui/material"; -import pvacd_logo from "../img/pvacd_logo.png"; -import meter_field from "../img/meter_field.jpg"; -import meter_storage from "../img/meter_storage.jpg"; +import { + Grid, + Card, + CardContent, + CardMedia, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from "@mui/material"; +import pvacd_logo from "@/img/pvacd_logo.png"; +import meter_field from "@/img/meter_field.jpg"; +import meter_storage from "@/img/meter_storage.jpg"; import HomeIcon from "@mui/icons-material/Home"; -import { BackgroundBox } from "../components/BackgroundBox"; -import { CustomCardHeader } from "../components/CustomCardHeader"; +import { CustomCardHeader, BackgroundBox } from "@/components"; export const Home = () => { const versionHistory = [ @@ -28,7 +37,15 @@ export const Home = () => { - + { }} /> - PVACD Meter Manager Info + + PVACD Meter Manager Info + Version History {versionHistory.map((version) => ( diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index 6b0a3c7e..5f07dca0 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -11,11 +11,11 @@ import { Stack, Grid, } from "@mui/material"; -import LoginIcon from '@mui/icons-material/Login'; +import { Login as LoginIcon } from "@mui/icons-material"; import { enqueueSnackbar } from "notistack"; -import { SecurityScope } from "../interfaces"; -import { API_URL } from "../config"; -import { CustomCardHeader } from "../components"; +import { SecurityScope } from "@/interfaces"; +import { API_URL } from "@/config"; +import { CustomCardHeader } from "@/components"; export const Login = () => { const [username, setUsername] = useState(""); @@ -38,7 +38,7 @@ export const Login = () => { .then(handleLogin) .catch((_) => { setError( - "Unable to connect to the server. Please check your internet connection and try again. If the issue persists, contact support." + "Unable to connect to the server. Please check your internet connection and try again. If the issue persists, contact support.", ); }); }; @@ -59,7 +59,7 @@ export const Login = () => { ) { enqueueSnackbar( "Your role does not have access to the site UI. Please try accessing data via our API.", - { variant: "error" } + { variant: "error" }, ); return; } @@ -95,10 +95,7 @@ export const Login = () => { }} > - + { }; export default Login; - diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index 70a108d3..245842e5 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -5,17 +5,17 @@ import { SelectedActivityDetails } from "./SelectedActivityDetails"; import { SelectedObservationDetails } from "./SelectedObservationDetails"; import { SelectedBlankCard } from "./SelectedBlankCard"; import { useLocation, useSearchParams } from "react-router-dom"; -import { useGetMeterHistory } from "../../../service/ApiServiceNew"; +import { useGetMeterHistory } from "@/service"; import { MeterHistoryDTO, PatchActivityForm, PatchObservationForm, -} from "../../../interfaces"; -import { MeterHistoryType } from "../../../enums"; +} from "@/interfaces"; +import { MeterHistoryType } from "@/enums"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; -import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "../../../components"; +import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "@/components"; import { ImageOutlined } from "@mui/icons-material"; dayjs.extend(utc); dayjs.extend(timezone); @@ -34,9 +34,7 @@ export const MeterHistory = ({ const photos = useMemo(() => { if (selectedHistoryItem?.history_type === MeterHistoryType.Activity) { - return ( - selectedHistoryItem.photos?.map((p: any) => p.url) ?? [] - ); + return selectedHistoryItem.photos?.map((p: any) => p.url) ?? []; } return []; }, [selectedHistoryItem]); diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index cc61ee32..8d222c32 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -7,9 +7,9 @@ import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -import { MeterHistoryType } from "../../../enums"; -import { MeterHistoryDTO } from "../../../interfaces"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; +import { MeterHistoryType } from "@/enums"; +import { MeterHistoryDTO } from "@/interfaces"; +import { CustomCardHeader } from "@/components"; export const MeterHistoryTable = ({ onHistoryItemSelection, diff --git a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx index c7b3f867..8507c5da 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx @@ -1,6 +1,6 @@ import { Grid, Card, CardContent } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; +import { CustomCardHeader } from "@/components"; // A blank card to display when no history item is selected export const SelectedBlankCard = () => { diff --git a/frontend/src/views/Meters/MeterHistory/SelectedHistoryDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedHistoryDetails.tsx index 6a35a349..4d153877 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedHistoryDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedHistoryDetails.tsx @@ -1,7 +1,7 @@ import { TextField, Grid, Card, CardContent, CardHeader } from "@mui/material"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { MeterHistoryType } from "../../../enums"; -import { NoteTypeLU } from "../../../interfaces"; +import { InfoOutlined } from "@mui/icons-material"; +import { MeterHistoryType } from "@/enums"; +import { NoteTypeLU } from "@/interfaces"; import dayjs from "dayjs"; const disabledInputStyle = { @@ -44,7 +44,7 @@ export default function SelectedHistoryDetails({ title={
Selected History Details - +
} sx={{ mb: 0, pb: 0 }} diff --git a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx index 5688a594..6491e47b 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx @@ -31,7 +31,7 @@ import { useGetPropertyTypes, useUpdateObservation, useDeleteObservation, -} from "@/service/ApiServiceNew"; +} from "@/service"; export const SelectedObservationDetails = ({ selectedObservation, diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index a2ae14dc..914da2d8 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { MeterSelectionTable } from "./MeterSelectionTable"; import MeterSelectionMap from "./MeterSelectionMap"; -import TabPanel from "../../../components/TabPanel"; import { Tabs, Tab, @@ -13,10 +12,9 @@ import { ToggleButton, InputAdornment, } from "@mui/material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import { MeterStatusNames } from "../../../enums"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; -import { Search } from "@mui/icons-material"; +import { FormatListBulletedOutlined, Search } from "@mui/icons-material"; +import { MeterStatusNames } from "@/enums"; +import { CustomCardHeader, TabPanel } from "@/components"; export const MeterSelection = ({ onMeterSelection, @@ -70,13 +68,10 @@ export const MeterSelection = ({ return ( - + - + - + - + @@ -97,12 +100,17 @@ export default function MeterSelectionMap({ > {meterMarkers.isSuccess && meterMarkers.data.map((meter: MeterMapDTO) => { - const color = meter.last_pm ? getMeterMarkerColor(meter.last_pm) : "black"; + const color = meter.last_pm + ? getMeterMarkerColor(meter.last_pm) + : "black"; return ( onMeterSelection(meter.id), }} @@ -162,18 +170,27 @@ export default function MeterSelectionMap({ {/* Loading and empty states */} {meterMarkers.isLoading && ( - Loading meter markers... + + Loading meter markers... + )} {meterMarkers.isSuccess && meterMarkers?.data.length === 0 && ( - + No meters found for that search. @@ -181,10 +198,14 @@ export default function MeterSelectionMap({ {/* Error */} {meterMarkers.isError && ( - + Failed to load meters: {meterMarkers.error.message} diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 80aa9f15..94c050a4 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -15,12 +15,6 @@ import { } from "@mui/material"; import { useQuery, useQueryClient } from "react-query"; import { useAuthUser } from "react-auth-kit"; -import { MonitoringWellsTable } from "./MonitoringWellsTable"; -import { MonitoringWellsPlot } from "./MonitoringWellsPlot"; -import { - CreateModal, - UpdateModal, -} from "../../components/Modals/MonitoredWell"; import { NewWellMeasurement, PatchWellMeasurement, @@ -28,19 +22,26 @@ import { SecurityScope, WellMeasurementDTO, MonitoredWell, -} from "../../interfaces"; +} from "@/interfaces"; import { useCreateWaterLevel, useUpdateWaterLevel, useDeleteWaterLevel, -} from "../../service/ApiServiceNew"; +} from "@/service"; import dayjs, { Dayjs } from "dayjs"; -import { useFetchWithAuth, useFetchST2 } from "../../hooks"; -import { getDataStreamId } from "../../utils/DataStreamUtils"; +import { enqueueSnackbar } from "notistack"; +import { useFetchWithAuth, useFetchST2 } from "@/hooks"; +import { getDataStreamId, separateAndSortMonitoredWells } from "@/utils"; import { MonitorHeart } from "@mui/icons-material"; -import { BackgroundBox } from "../../components/BackgroundBox"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; -import { separateAndSortMonitoredWells } from "../../utils"; +import { + CreateModal, + UpdateModal, + CustomCardHeader, + BackgroundBox, +} from "@/components"; + +import { MonitoringWellsTable } from "./MonitoringWellsTable"; +import { MonitoringWellsPlot } from "./MonitoringWellsPlot"; export const MonitoringWellsView = () => { const theme = useTheme(); @@ -50,13 +51,14 @@ export const MonitoringWellsView = () => { const fetchSt2 = useFetchST2(); const selectWellId = useId(); const [wellId, setWellId] = useState(); - const [selectedMeasurement, setSelectedMeasurement] = - useState({ - levelmeasurement_id: 0, - timestamp: dayjs(), - value: 0, - submitting_user_id: 0, - }); + const [selectedMeasurement, setSelectedMeasurement] = useState< + Partial + >({ + levelmeasurement_id: 0, + timestamp: dayjs(), + value: 0, + submitting_user_id: 0, + }); const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); @@ -142,7 +144,7 @@ export const MonitoringWellsView = () => { errorSt2 || errorJohnsonSensorData; - const handleSubmitNewMeasurement = (data: NewWellMeasurement) => { + const handleSubmitNewMeasurement = (data: Partial) => { if (wellId) { data.well_id = wellId; createMeasurement.mutate(data, { @@ -170,12 +172,27 @@ export const MonitoringWellsView = () => { const handleDeleteMeasurement = () => { setIsUpdateModalOpen(false); + + const id = selectedMeasurement.levelmeasurement_id; + if (!id) { + enqueueSnackbar("No measurement selected to delete.", { + variant: "warning", + }); + return; + } + if (window.confirm("Are you sure you want to delete this measurement?")) { - deleteMeasurement.mutate(selectedMeasurement.levelmeasurement_id, { + deleteMeasurement.mutate(id, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["manualMeasurements", wellId], }); + enqueueSnackbar("Measurement deleted.", { variant: "success" }); + }, + onError: (e: any) => { + enqueueSnackbar(e?.message ?? "Failed to delete measurement.", { + variant: "error", + }); }, }); } @@ -365,16 +382,18 @@ export const MonitoringWellsView = () => { {authUser() && ( <> setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} measurement={selectedMeasurement} onUpdateMeasurement={(update) => - setSelectedMeasurement({ ...selectedMeasurement, ...update }) + setSelectedMeasurement((prev) => ({ ...prev, ...update })) } onSubmitUpdate={handleSubmitMeasurementUpdate} onDeleteMeasurement={handleDeleteMeasurement} diff --git a/frontend/src/views/NotFound.tsx b/frontend/src/views/NotFound.tsx index 3d176f94..6f5d901c 100644 --- a/frontend/src/views/NotFound.tsx +++ b/frontend/src/views/NotFound.tsx @@ -1,17 +1,17 @@ import { Box, Button, Card, CardContent, Typography } from "@mui/material"; -import DoNotTouchIcon from '@mui/icons-material/DoNotTouch'; -import { BackgroundBox, CustomCardHeader } from "../components"; +import { Home, DoNotTouch } from "@mui/icons-material"; import { Link } from "react-router-dom"; -import { Home } from "@mui/icons-material"; +import { BackgroundBox, CustomCardHeader } from "@/components"; export const NotFound = () => { return ( - + - Sorry, the page you are looking for does not exist or may have been moved. + Sorry, the page you are looking for does not exist or may have been + moved. @@ -28,4 +28,4 @@ export const NotFound = () => { ); -} +}; diff --git a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx index 915744e5..c0a7df86 100644 --- a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx +++ b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx @@ -6,10 +6,7 @@ import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; -import { - useCreateMeterType, - useUpdateMeterType, -} from "@/service/ApiServiceNew"; +import { useCreateMeterType, useUpdateMeterType } from "@/service"; import { ControlledTextbox, ControlledSelectNonObject, diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index 13c01d6c..ee2399ca 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -11,7 +11,7 @@ import { Typography, } from "@mui/material"; import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; -import { useGetMeterTypeList } from "@/service/ApiServiceNew"; +import { useGetMeterTypeList } from "@/service"; import { MeterTypeLU } from "@/interfaces"; import { CustomCardHeader, diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index f29e04a4..7a4e80c7 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -20,13 +20,12 @@ import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; import { useFieldArray } from "react-hook-form"; - import { useCreatePart, useGetMeterTypeList, useGetPart, useUpdatePart, -} from "@/service/ApiServiceNew"; +} from "@/service"; import { ControlledTextbox, ControlledPartTypeSelect, diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 1242f578..f9f6f433 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -17,7 +17,7 @@ import { FormatListBulletedOutlined, } from "@mui/icons-material"; import { useSnackbar } from "notistack"; -import { useGetParts, useAddParts } from "@/service/ApiServiceNew"; +import { useGetParts, useAddParts } from "@/service"; import { Part } from "@/interfaces"; import { CustomCardHeader, diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index 18b4d7ab..77e51379 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from "react"; -import { PartsTable } from "./PartsTable"; import { Grid } from "@mui/material"; +import { MeterTypeLU } from "@/interfaces"; +import { BackgroundBox } from "@/components"; + +import { PartsTable } from "./PartsTable"; +import { MeterTypeDetailsCard } from "./MeterTypeDetailsCard"; import { PartDetailsCard } from "./PartDetailsCard"; import { MeterTypesTable } from "./MeterTypesTable"; -import { MeterTypeDetailsCard } from "./MeterTypeDetailsCard"; -import { MeterTypeLU } from "../../interfaces"; -import { BackgroundBox } from "../../components/BackgroundBox"; export const PartsView = () => { const [selectedPartID, setSelectedPartID] = useState(); @@ -50,6 +51,6 @@ export const PartsView = () => { /> - + ); }; diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index e56aeaf0..6a4ba6d0 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -1,4 +1,5 @@ -import * as yup from 'yup'; +import { useEffect, useState } from "react"; +import * as yup from "yup"; import { enqueueSnackbar } from "notistack"; import { yupResolver } from "@hookform/resolvers/yup"; import { useForm, Controller } from "react-hook-form"; @@ -24,23 +25,23 @@ import { } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; import { useAuthUser, useSignIn } from "react-auth-kit"; +import { Check, Close, Edit, ExpandMore } from "@mui/icons-material"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { - Check, - Close, - Edit, - ExpandMore -} from '@mui/icons-material'; -import { BackgroundBox, CustomCardHeader, ImageUploadWithPreview, IsTrueChip, RoleChip } from "../components"; -import { navConfig } from '../constants'; -import { useFetchWithAuth } from '../hooks'; -import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { SecurityScope } from '../interfaces'; -import { useEffect, useState } from 'react'; + BackgroundBox, + CustomCardHeader, + ImageUploadWithPreview, + IsTrueChip, + RoleChip, +} from "@/components"; +import { navConfig } from "@/constants"; +import { useFetchWithAuth } from "@/hooks"; +import { SecurityScope } from "@/interfaces"; const redirectOptions = { - public: navConfig.filter(item => !item.role), - technician: navConfig.filter(item => item.role === "Technician"), - admin: navConfig.filter(item => item.role === "Admin"), + public: navConfig.filter((item) => !item.role), + technician: navConfig.filter((item) => item.role === "Technician"), + admin: navConfig.filter((item) => item.role === "Admin"), }; const redirectSchema = yup.object().shape({ @@ -63,8 +64,8 @@ export const Settings = () => { const fetchWithAuth = useFetchWithAuth(); const scopes: Set = new Set( authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string - ) ?? [] + (scope: SecurityScope) => scope.scope_string, + ) ?? [], ); const hasReadScope = scopes.has("read"); @@ -89,17 +90,19 @@ export const Settings = () => { }); }, onSuccess: (responseJson: any) => { - enqueueSnackbar("Display name updated successfully.", { variant: "success" }); + enqueueSnackbar("Display name updated successfully.", { + variant: "success", + }); // Grab the current auth state & update it if (user) { signIn({ token: localStorage.getItem("_auth")!, // reuse current token - expiresIn: 300, // reuse the expiry window you want + expiresIn: 300, // reuse the expiry window you want tokenType: "bearer", authState: { ...user, - display_name: responseJson.display_name, // overwrite just this field + display_name: responseJson.display_name, // overwrite just this field }, }); } @@ -110,16 +113,17 @@ export const Settings = () => { }); const onDisplayNameSubmit = ({ display_name }: { display_name: string }) => { - displayNameMutation.mutate({ display_name }) - } + displayNameMutation.mutate({ display_name }); + }; const queryClient = useQueryClient(); const getRedirectPageQuery = useQuery({ queryKey: ["redirectPage"], - queryFn: async () => fetchWithAuth({ - method: "GET", - route: "/settings/redirect_page", - }), + queryFn: async () => + fetchWithAuth({ + method: "GET", + route: "/settings/redirect_page", + }), }); const redirectMutation = useMutation({ @@ -130,19 +134,21 @@ export const Settings = () => { body: data, }); }, - onSuccess: (responseJson: { message: string, redirect_page: string }) => { - enqueueSnackbar("Redirect page updated successfully.", { variant: "success" }); + onSuccess: (responseJson: { message: string; redirect_page: string }) => { + enqueueSnackbar("Redirect page updated successfully.", { + variant: "success", + }); queryClient.invalidateQueries(["redirectPage"]); // Grab the current auth state & update it if (user) { signIn({ token: localStorage.getItem("_auth")!, // reuse current token - expiresIn: 300, // reuse the expiry window you want + expiresIn: 300, // reuse the expiry window you want tokenType: "bearer", authState: { ...user, - redirect_page: responseJson.redirect_page, // overwrite just this field + redirect_page: responseJson.redirect_page, // overwrite just this field }, }); } @@ -155,10 +161,12 @@ export const Settings = () => { const { control: redirectControl, handleSubmit: handleRedirectSubmit, - reset: redirectReset + reset: redirectReset, } = useForm({ resolver: yupResolver(redirectSchema), - defaultValues: { redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/" }, + defaultValues: { + redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/", + }, values: { redirect_page: getRedirectPageQuery?.data?.redirect_page ?? "/" }, // react-hook-form v7 pattern for sync }); @@ -189,7 +197,9 @@ export const Settings = () => { return await res.json(); }, onSuccess: () => { - enqueueSnackbar("Password reset request submitted.", { variant: "success" }); + enqueueSnackbar("Password reset request submitted.", { + variant: "success", + }); }, }); @@ -225,19 +235,55 @@ export const Settings = () => { - + Full Name: - + - + Email: - + - + Username: - + - + {!isEditing ? ( <> Display Name: @@ -246,7 +292,10 @@ export const Settings = () => { label={user?.display_name ?? "N/A"} variant="outlined" /> - setIsEditing(true)}> + setIsEditing(true)} + > @@ -269,24 +318,41 @@ export const Settings = () => { { - displayNameReset({ display_name: user?.display_name ?? "" }); + displayNameReset({ + display_name: user?.display_name ?? "", + }); setIsEditing(false); }} > - +
)}
- + Role: - + Active: @@ -312,7 +378,9 @@ export const Settings = () => { }> - Redirect Page After Login + + Redirect Page After Login + @@ -326,13 +394,22 @@ export const Settings = () => { render={({ field }) => { // flatten all available paths const availablePaths = [ - ...redirectOptions.public.map(o => o.path), - ...(hasReadScope ? redirectOptions.technician.map(o => o.path) : []), - ...(hasAdminScope ? redirectOptions.admin.map(o => o.path) : []), + ...redirectOptions.public.map((o) => o.path), + ...(hasReadScope + ? redirectOptions.technician.map( + (o) => o.path, + ) + : []), + ...(hasAdminScope + ? redirectOptions.admin.map((o) => o.path) + : []), ]; // guard: if no options available yet, render empty select - if (getRedirectPageQuery.isFetching && availablePaths.length === 0) { + if ( + getRedirectPageQuery.isFetching && + availablePaths.length === 0 + ) { return ( { ); } - const safeValue = availablePaths.includes(field.value) + const safeValue = availablePaths.includes( + field.value, + ) ? field.value : "/"; @@ -352,66 +431,122 @@ export const Settings = () => { {...field} select fullWidth - size='small' + size="small" label="Page to redirect after login" - disabled={getRedirectPageQuery?.isFetching || redirectMutation.isLoading} + disabled={ + getRedirectPageQuery?.isFetching || + redirectMutation.isLoading + } value={safeValue} onChange={(e) => field.onChange(e)} > {redirectOptions.public.length > 0 && [ - + Pages , - ...redirectOptions.public.map((option) => { - const Icon = option.icon; - return ( - - - - - - {option.label} - - - ); - }), - ]} - {hasReadScope && redirectOptions.technician.length > 0 && [ - - Pages - , - ...redirectOptions.technician.map((option) => { - const Icon = option.icon; - return ( - - - - - - {option.label}{option.parent === "reports" ? " Report" : null} - - - ); - }), - ]} - {hasAdminScope && redirectOptions.admin.length > 0 && [ - - Pages - , - ...redirectOptions.admin.map((option) => { - const Icon = option.icon; - return ( - - - - - - {option.label} - - - ); - }), + ...redirectOptions.public.map( + (option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + + + ); + }, + ), ]} + {hasReadScope && + redirectOptions.technician.length > 0 && [ + + Pages + , + ...redirectOptions.technician.map( + (option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + {option.parent === "reports" + ? " Report" + : null} + + + ); + }, + ), + ]} + {hasAdminScope && + redirectOptions.admin.length > 0 && [ + + Pages + , + ...redirectOptions.admin.map( + (option) => { + const Icon = option.icon; + return ( + + + + + + {option.label} + + + ); + }, + ), + ]} ); }} @@ -446,10 +581,12 @@ export const Settings = () => { {...field} type="password" fullWidth - size='small' + size="small" label="Current Password" error={!!passwordErrors.currentPassword} - helperText={passwordErrors.currentPassword?.message} + helperText={ + passwordErrors.currentPassword?.message + } /> )} /> @@ -463,10 +600,12 @@ export const Settings = () => { {...field} type="password" fullWidth - size='small' + size="small" label="New Password" error={!!passwordErrors.newPassword} - helperText={passwordErrors.newPassword?.message} + helperText={ + passwordErrors.newPassword?.message + } /> )} /> @@ -480,10 +619,12 @@ export const Settings = () => { {...field} type="password" fullWidth - size='small' + size="small" label="Confirm Password" error={!!passwordErrors.confirmPassword} - helperText={passwordErrors.confirmPassword?.message} + helperText={ + passwordErrors.confirmPassword?.message + } /> )} /> @@ -507,7 +648,6 @@ export const Settings = () => {
- + ); }; - diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 03c25c36..519b4b0c 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -10,7 +10,7 @@ import { Tooltip, } from "@mui/material"; import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; -import { useGetSecurityScopes } from "@/service/ApiServiceNew"; +import { useGetSecurityScopes } from "@/service"; import { SecurityScope } from "@/interfaces"; import { CustomCardHeader, GridFooterWithButton } from "@/components"; diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 2c63d0ab..7aa784ad 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -20,11 +20,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; import { useFieldArray } from "react-hook-form"; -import { - useCreateRole, - useGetSecurityScopes, - useUpdateRole, -} from "@/service/ApiServiceNew"; +import { useCreateRole, useGetSecurityScopes, useUpdateRole } from "@/service"; import { ControlledTextbox, CustomCardHeader } from "@/components"; import { SecurityScope, UserRole } from "@/interfaces"; diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index fa3d6ca1..240f2f7c 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -10,7 +10,7 @@ import { TextField, } from "@mui/material"; import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; -import { useGetRoles } from "@/service/ApiServiceNew"; +import { useGetRoles } from "@/service"; import { UserRole } from "@/interfaces"; import { CustomCardHeader, GridFooterWithButton } from "@/components"; diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 33a67a42..ed7ea211 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -28,7 +28,7 @@ import { useUpdateUser, useGetRoles, useUpdateUserPassword, -} from "@/service/ApiServiceNew"; +} from "@/service"; import { ControlledTextbox, ControlledSelect, diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index e3a38a6b..1d5c7bf2 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -1,12 +1,13 @@ import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; +import { User, UserRole } from "@/interfaces"; +import { BackgroundBox } from "@/components"; + import { UsersTable } from "./UsersTable"; import { UserDetailsCard } from "./UserDetailsCard"; -import { User, UserRole } from "../../interfaces"; import { RolesTable } from "./RolesTable"; import { RoleDetailsCard } from "./RoleDetailsCard"; import { PermissionsTable } from "./PermissionsTable"; -import { BackgroundBox } from "../../components/BackgroundBox"; export const UserManagementView = () => { const [selectedUser, setSelectedUser] = useState(); @@ -53,6 +54,6 @@ export const UserManagementView = () => {
- + ); }; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 34a2bcc1..886390ea 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -10,7 +10,7 @@ import { Typography, } from "@mui/material"; import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; -import { useGetUserAdminList } from "@/service/ApiServiceNew"; +import { useGetUserAdminList } from "@/service"; import { User } from "@/interfaces"; import { CustomCardHeader, diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index a8c3b54f..b38ade72 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -23,7 +23,7 @@ import { useGetWaterSources, useGetWellStatusTypes, useUpdateWell, -} from "@/service/ApiServiceNew"; +} from "@/service"; import { SubmitWellCreate, WellUpdate, diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index c622f495..7340fce7 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -2,11 +2,16 @@ import { useEffect } from "react"; import { useDebounce } from "use-debounce"; import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; import { Box, Typography } from "@mui/material"; -import { useGetWellLocations } from "../../service/ApiServiceNew"; -import { Well } from "../../interfaces"; -import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, WellMapLegend } from "../../components"; -import { BlueMapIcon, RedMapIcon, BlackMapIcon } from "../../components/MapIcons"; -import { WellStatus } from "../../enums"; +import { useGetWellLocations } from "@/service"; +import { Well } from "@/interfaces"; +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"; @@ -38,16 +43,16 @@ export default function WellSelectionMap({ @@ -109,27 +114,41 @@ export default function WellSelectionMap({ {/* Loading first page */} {wellQuery.isLoading && ( - Loading well markers... + + Loading well markers... + )} {/* Loading additional pages */} {wellQuery.isFetchingNextPage && ( - Loading more wells... + + Loading more wells... + )} {wellQuery.isSuccess && wellMarkers.length === 0 && ( - + No wells found for that search. @@ -137,10 +156,14 @@ export default function WellSelectionMap({ {/* Error */} {wellQuery.isError && ( - + Failed to load wells: {wellQuery.error.message} @@ -157,5 +180,4 @@ const getWellIcon = (well: Well) => { return RedMapIcon; } return BlueMapIcon; -} - +}; diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index a4311fd0..0a2e2848 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -6,7 +6,7 @@ import { useAuthUser } from "react-auth-kit"; import { Box, Button, Stack } from "@mui/material"; import { Add } from "@mui/icons-material"; import { SecurityScope, Well, WellListQueryParams } from "@/interfaces"; -import { useGetWells } from "@/service/ApiServiceNew"; +import { useGetWells } from "@/service"; import { SortDirection, WellSortByField } from "@/enums"; import { GridFooterWithButton } from "@/components"; diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index 72d19fd2..c4738184 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -9,12 +9,12 @@ import { Box, InputAdornment, } from "@mui/material"; -import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; +import { FormatListBulletedOutlined } from "@mui/icons-material"; import { Search } from "@mui/icons-material"; -import TabPanel from "../../components/TabPanel"; +import { CustomCardHeader, TabPanel } from "@/components"; + import WellSelectionTable from "./WellSelectionTable"; import WellSelectionMap from "./WellSelectionMap"; -import { CustomCardHeader } from "../../components/CustomCardHeader"; export const WellsTable = ({ setSelectedWell, @@ -29,22 +29,34 @@ export const WellsTable = ({ setCurrentTabIndex(newTabIndex); return ( - - + + - + - + Date: Tue, 10 Feb 2026 13:20:15 -0600 Subject: [PATCH 22/91] refactor(interfaces): Rm interfaces from interfaces.d.ts to their own files --- frontend/src/interfaces.d.ts | 686 ------------------ frontend/src/interfaces/ActivityForm.ts | 38 + .../src/interfaces/ActivityFormControl.ts | 41 ++ frontend/src/interfaces/ActivityTypeLU.ts | 6 + frontend/src/interfaces/BaseWell.ts | 15 + frontend/src/interfaces/CreateUser.ts | 10 + frontend/src/interfaces/LandOwner.ts | 9 + frontend/src/interfaces/Location.ts | 12 + frontend/src/interfaces/Meter.ts | 25 + frontend/src/interfaces/MeterActivity.ts | 22 + frontend/src/interfaces/MeterDetails.ts | 26 + .../src/interfaces/MeterDetailsQueryParams.ts | 3 + frontend/src/interfaces/MeterHistoryDTO.ts | 14 + frontend/src/interfaces/MeterListDTO.ts | 16 + frontend/src/interfaces/MeterListQuery.ts | 10 + .../src/interfaces/MeterListQueryParams.ts | 10 + frontend/src/interfaces/MeterListSortBy.ts | 3 + frontend/src/interfaces/MeterMapDTO.ts | 13 + frontend/src/interfaces/MeterPartParams.ts | 3 + frontend/src/interfaces/MeterRegister.ts | 13 + frontend/src/interfaces/MeterStatus.ts | 5 + frontend/src/interfaces/MeterType.ts | 10 + frontend/src/interfaces/MeterTypeLU.ts | 10 + frontend/src/interfaces/MonitoredRegion.ts | 9 + frontend/src/interfaces/MonitoredWell.ts | 11 + .../src/interfaces/NewRegionMeasurement.ts | 7 + frontend/src/interfaces/NewUser.ts | 11 + frontend/src/interfaces/NewWellMeasurement.ts | 7 + frontend/src/interfaces/NewWorkOrder.ts | 7 + frontend/src/interfaces/NoteTypeLU.ts | 7 + frontend/src/interfaces/ObservationForm.ts | 8 + .../src/interfaces/ObservedPropertyTypeLU.ts | 10 + frontend/src/interfaces/Organization.ts | 3 + frontend/src/interfaces/Page.ts | 6 + frontend/src/interfaces/Part.ts | 18 + frontend/src/interfaces/PartAssociation.ts | 10 + frontend/src/interfaces/PartTypeLU.ts | 7 + frontend/src/interfaces/PatchActivityForm.ts | 29 + .../src/interfaces/PatchActivitySubmit.ts | 19 + .../src/interfaces/PatchObservationForm.ts | 21 + .../src/interfaces/PatchObservationSubmit.ts | 15 + .../src/interfaces/PatchRegionMeasurement.ts | 9 + .../src/interfaces/PatchWellMeasurement.ts | 8 + frontend/src/interfaces/PatchWorkOrder.ts | 9 + .../src/interfaces/RegionMeasurementDTO.ts | 7 + frontend/src/interfaces/ST2Measurement.ts | 6 + frontend/src/interfaces/ST2Response.ts | 5 + .../interfaces/ST2WaterLevelQueryParams.ts | 5 + frontend/src/interfaces/SecurityScope.ts | 5 + frontend/src/interfaces/ServiceTypeLU.ts | 5 + frontend/src/interfaces/SubmitWellCreate.ts | 22 + frontend/src/interfaces/Unit.ts | 6 + .../src/interfaces/UpdatedUserPassword.ts | 4 + frontend/src/interfaces/User.ts | 14 + frontend/src/interfaces/UserRole.ts | 7 + .../src/interfaces/WaterLevelQueryParams.ts | 3 + frontend/src/interfaces/WaterSource.ts | 5 + frontend/src/interfaces/Well.ts | 21 + .../src/interfaces/WellDetailsQueryParams.ts | 3 + .../src/interfaces/WellListQueryParams.ts | 10 + frontend/src/interfaces/WellMeasurementDTO.ts | 8 + frontend/src/interfaces/WellMergeParams.ts | 4 + frontend/src/interfaces/WellStatus.ts | 5 + frontend/src/interfaces/WellUpdate.ts | 12 + frontend/src/interfaces/WellUseLU.ts | 6 + frontend/src/interfaces/WorkOrder.ts | 13 + frontend/src/interfaces/index.ts | 66 ++ frontend/src/interfaces/primitives.ts | 3 + frontend/src/service/ApiServiceNew.ts | 11 +- frontend/src/utils/AssertDefined.ts | 8 + frontend/src/utils/index.ts | 1 + .../src/views/Meters/MeterDetailsFields.tsx | 15 +- .../Meters/MeterHistory/MeterHistory.tsx | 20 +- .../MeterSelection/MeterSelectionTable.tsx | 15 +- frontend/src/views/Meters/MetersView.tsx | 8 +- .../src/views/Reports/Chlorides/index.tsx | 6 +- .../views/WellManagement/WellSelectionMap.tsx | 4 +- .../WellManagement/WellSelectionTable.tsx | 6 +- 78 files changed, 846 insertions(+), 724 deletions(-) delete mode 100644 frontend/src/interfaces.d.ts create mode 100644 frontend/src/interfaces/ActivityForm.ts create mode 100644 frontend/src/interfaces/ActivityFormControl.ts create mode 100644 frontend/src/interfaces/ActivityTypeLU.ts create mode 100644 frontend/src/interfaces/BaseWell.ts create mode 100644 frontend/src/interfaces/CreateUser.ts create mode 100644 frontend/src/interfaces/LandOwner.ts create mode 100644 frontend/src/interfaces/Location.ts create mode 100644 frontend/src/interfaces/Meter.ts create mode 100644 frontend/src/interfaces/MeterActivity.ts create mode 100644 frontend/src/interfaces/MeterDetails.ts create mode 100644 frontend/src/interfaces/MeterDetailsQueryParams.ts create mode 100644 frontend/src/interfaces/MeterHistoryDTO.ts create mode 100644 frontend/src/interfaces/MeterListDTO.ts create mode 100644 frontend/src/interfaces/MeterListQuery.ts create mode 100644 frontend/src/interfaces/MeterListQueryParams.ts create mode 100644 frontend/src/interfaces/MeterListSortBy.ts create mode 100644 frontend/src/interfaces/MeterMapDTO.ts create mode 100644 frontend/src/interfaces/MeterPartParams.ts create mode 100644 frontend/src/interfaces/MeterRegister.ts create mode 100644 frontend/src/interfaces/MeterStatus.ts create mode 100644 frontend/src/interfaces/MeterType.ts create mode 100644 frontend/src/interfaces/MeterTypeLU.ts create mode 100644 frontend/src/interfaces/MonitoredRegion.ts create mode 100644 frontend/src/interfaces/MonitoredWell.ts create mode 100644 frontend/src/interfaces/NewRegionMeasurement.ts create mode 100644 frontend/src/interfaces/NewUser.ts create mode 100644 frontend/src/interfaces/NewWellMeasurement.ts create mode 100644 frontend/src/interfaces/NewWorkOrder.ts create mode 100644 frontend/src/interfaces/NoteTypeLU.ts create mode 100644 frontend/src/interfaces/ObservationForm.ts create mode 100644 frontend/src/interfaces/ObservedPropertyTypeLU.ts create mode 100644 frontend/src/interfaces/Organization.ts create mode 100644 frontend/src/interfaces/Page.ts create mode 100644 frontend/src/interfaces/Part.ts create mode 100644 frontend/src/interfaces/PartAssociation.ts create mode 100644 frontend/src/interfaces/PartTypeLU.ts create mode 100644 frontend/src/interfaces/PatchActivityForm.ts create mode 100644 frontend/src/interfaces/PatchActivitySubmit.ts create mode 100644 frontend/src/interfaces/PatchObservationForm.ts create mode 100644 frontend/src/interfaces/PatchObservationSubmit.ts create mode 100644 frontend/src/interfaces/PatchRegionMeasurement.ts create mode 100644 frontend/src/interfaces/PatchWellMeasurement.ts create mode 100644 frontend/src/interfaces/PatchWorkOrder.ts create mode 100644 frontend/src/interfaces/RegionMeasurementDTO.ts create mode 100644 frontend/src/interfaces/ST2Measurement.ts create mode 100644 frontend/src/interfaces/ST2Response.ts create mode 100644 frontend/src/interfaces/ST2WaterLevelQueryParams.ts create mode 100644 frontend/src/interfaces/SecurityScope.ts create mode 100644 frontend/src/interfaces/ServiceTypeLU.ts create mode 100644 frontend/src/interfaces/SubmitWellCreate.ts create mode 100644 frontend/src/interfaces/Unit.ts create mode 100644 frontend/src/interfaces/UpdatedUserPassword.ts create mode 100644 frontend/src/interfaces/User.ts create mode 100644 frontend/src/interfaces/UserRole.ts create mode 100644 frontend/src/interfaces/WaterLevelQueryParams.ts create mode 100644 frontend/src/interfaces/WaterSource.ts create mode 100644 frontend/src/interfaces/Well.ts create mode 100644 frontend/src/interfaces/WellDetailsQueryParams.ts create mode 100644 frontend/src/interfaces/WellListQueryParams.ts create mode 100644 frontend/src/interfaces/WellMeasurementDTO.ts create mode 100644 frontend/src/interfaces/WellMergeParams.ts create mode 100644 frontend/src/interfaces/WellStatus.ts create mode 100644 frontend/src/interfaces/WellUpdate.ts create mode 100644 frontend/src/interfaces/WellUseLU.ts create mode 100644 frontend/src/interfaces/WorkOrder.ts create mode 100644 frontend/src/interfaces/primitives.ts create mode 100644 frontend/src/utils/AssertDefined.ts diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts deleted file mode 100644 index 24d410ec..00000000 --- a/frontend/src/interfaces.d.ts +++ /dev/null @@ -1,686 +0,0 @@ -import { SortDirection, MeterSortByField, WellSortByField } from 'enums' -import internal from 'stream' -import { ActivityType, MeterStatusNames } from './enums' -import { DateCalendarClassKey } from '@mui/x-date-pickers' -import dayjs from 'dayjs' -import exp from 'constants' - -export interface ActivityForm { - - activity_details?: { - meter_id?: number - activity_type_id?: number - user_id?: number - date?: Dayjs - start_time?: Dayjs - end_time?: Dayjs - share_ose: boolean - work_order_id?: number - } - - current_installation?: { - contact_name?: string - contact_phone?: string - well_id?: number - notes?: string - water_users?: string - meter_owner?: string - } - - observations?: ObservationForm[] - - maintenance_repair?: { - service_type_ids: number[] - description: string - } - - notes?: { - working_on_arrival_slug: string - selected_note_ids: number[] - } - - part_used_ids?: number[] -} - -// This might could be the full things that are selected, but for now its only the things that are submitted/validated -// These need to be the actual interfaces eventually, meter -> MeterListDTO -export interface ActivityFormControl { - activity_details: { - selected_meter: Partial | null - activity_type: Partial | null - user: Partial | null - date: Dayjs - start_time: Dayjs - end_time: Dayjs - share_ose: boolean = false - work_order_id: number | null - }, - current_installation: { - meter: Partial | null - well: Partial | null - }, - observations: Array<{ - time: Dayjs - reading: '' | number - property_type_id: number | null - unit_id: number | null - }>, - maintenance_repair?: { - service_type_ids: number[] | null, - description: string - }, - notes: { - working_on_arrival_slug: string, - selected_note_ids: number[] | null - }, - photos?: File[], - part_used_ids?: [] -} - -export interface MeterActivity { - id: int - timestamp_start: Date - timestamp_end: Date - notes?: string - submitting_user_id: int - meter_id: int - activity_type_id: int - location_id: int - - submitting_user?: User - meter?: Meter - activity_type?: ActivityTypeLU - location?: Location - parts_used?: [] -} - -//This is designed to match the HistoryDetails form rather than the patch meter API -export interface PatchActivityForm { - activity_id: int - meter_id: int - activity_date: dayjs.Dayjs - activity_start_time: dayjs.Dayjs - activity_end_time: dayjs.Dayjs - activity_type: ActivityTypeLU - submitting_user: User - description: string - - well: Well | null - water_users?: string - - notes?: NoteTypeLU[] - services?: ServiceTypeLU[] - parts_used?: Part[] - - ose_share: boolean -} - -//This interface is designed to match the backend API patch endpoint -export interface PatchActivitySubmit { - activity_id: int - timestamp_start: string - timestamp_end: string - description: string - submitting_user_id: int - meter_id: int - activity_type_id: int - location_id: int | null - ose_share: boolean - water_users: string - - note_ids: int[] | null - service_ids: int[] | null - part_ids: int[] | null -} - -//Designed for the HistoryDetails component, not the patch endpoint -export interface PatchObservationForm { - observation_id: int - submitting_user: User - well: Well | null - observation_date: dayjs.Dayjs - observation_time: dayjs.Dayjs - property_type: ObservedPropertyTypeLU - unit: Unit - value: number - ose_share: boolean - notes?: string - meter_id: int -} - -export interface PatchObservationSubmit { - //Matches the backend API patch endpoint - observation_id: int - timestamp: string - value: number - notes: string | null - submitting_user_id: int - meter_id: int - observed_property_type_id: int - unit_id: int - location_id: int | null - ose_share: boolean -} - -export interface ObservationForm { - time: Dayjs - reading: '' | number - property_type_id: '' | number - unit_id: '' | number -} - -export interface WellUseLU { - id: number - use_type?: string - code?: string - description?: string -} - -export interface PartTypeLU { - id: int - name: string - description?: string -} - -export interface Part { - id: number - part_number: string - part_type_id: number - vendor?: string - note?: string - description?: string - initial_count?: number - current_count?: number - in_use: boolean - commonly_used: boolean - - part_type?: PartTypeLU - meter_types?: MeterTypeLU[] - -} - -export interface PartAssociation { - id: int - meter_type_id: int - part_id: int - commonly_used: boolean - part?: Part -} - -export interface ServiceTypeLU { - id: number - service_name: string - description?: string -} - -export interface NoteTypeLU { - id: number - note: string - details?: string - slug?: string - commonly_used: boolean -} - -export interface WellUseLU { - id: number - use_type: string - code: string - description: string -} - -export interface WaterSource { - id: number - name: string - description: string -} - -export interface WellStatus { - id: number - status: string - description: string -} - -export interface SubmitWellCreate { - name: string - ra_number: string - owners: string - osetag: string - water_source: WaterSource | null - chloride_group_id: number | null - - use_type: { - id: number - } - - location: { - name: string, - trss: string, - longitude: float, - latitude: float - } -} - -interface BaseWell { - id: number - name: string - ra_number: string - owners: string - osetag: string - casing: string - total_depth: number - outside_recorder: boolean - location_id: number - use_type_id: number - well_status_id: number - water_source_id: number - chloride_group_id: number | null -} - -export interface Well extends BaseWell { - use_type: WellUseLU | null - water_source: WaterSource | null - location: Location | null - well_status: WellStatus | null - - meters: [ - { - id: int - serial_number: string - water_users?: string - } - ] -} - -export interface WellUpdate extends BaseWell { - use_type: WellUseLU - water_source: WaterSource - location: Location - well_status: WellStatus -} - -export interface MeterDetailsQueryParams { - meter_id: number | undefined -} - -export interface MeterPartParams { - meter_id: number | undefined -} - -export interface WellDetailsQueryParams { - well_id: number | undefined -} - -export interface WaterLevelQueryParams { - well_id: number | undefined -} - -export interface WellMergeParams { - merge_well: string - target_well: string -} - -export interface ST2WaterLevelQueryParams { - $filter: string - $orderby: string - datastreamID: number | undefined -} - -export interface ActivityTypeLU { - id: number - name: string - description: string - permission: string -} - -export interface ObservedPropertyTypeLU { - id: number - name: string - description: string - context: string - - units?: Unit[] -} - -export interface Unit { - id: number - name: string - name_short: string - description: string -} - -export interface MeterHistoryDTO { - id: int - history_type: string - activity_type: string - date: Date - history_item: any - location: Location - well: Well | null - photos: any -} - -export interface MeterType { - id?: int - brand?: string - series?: string - model?: string - size?: float - description?: string -} - -export interface MeterRegister { - id: number - brand: string - meter_size: number - ratio: string | null - number_of_digits: number | null - decimal_digits: number | null - dial_units: Unit - totalizer_units: Unit - multiplier?: number | null -} - -export interface MeterStatus { - id: number - status_name?: string - description?: string -} - -export interface LandOwner { - id: number - contact_name?: string - land_owner_name?: string - organization?: string - phone?: string - email?: string - city?: string -} - -export interface Location { - name: string - latitude: float - longitude: float - trss: string - land_owner_id: number - - land_owner?: LandOwner -} - -//Depricate this??? need to assess -export interface MeterTypeLU { - id: number - brand: string - series: string - model: string - size: number - description: string - in_use: boolean -} - -export interface MeterDetails { - id?: number | null - serial_number?: string | null - contact_name?: string | null - contact_phone?: string | null - water_users?: string | null - meter_owner?: string | null - ra_number?: string | null - tag?: string | null - well_distance_ft?: float | null - notes?: string | null - meter_type_id?: int | null - well_id?: int | null - - meter_type: MeterType - status: MeterStatus - well: Well | null - meter_register: MeterRegister | null - // Also has parts_associated?: List[Part] -} - -export interface MeterListQueryParams { - search_string?: string - filter_by_status?: MeterStatusNames[] - sort_by?: MeterSortByField - sort_direction?: SortDirection - limit?: number - offset?: number -} - -export interface MeterMapDTO { - id: number - serial_number: string - well: { - ra_number: string - name: string - } - location: { - longitude: number - latitude: number - } - last_pm: string -} - -export interface Organization { - organization_name: string -} - -export interface Meter { - id: number - serial_number: string - contact_name?: string - contact_phone?: string - notes?: string - price?: number - - meter_type_id: number - status_id?: number - well_id: number - location_id?: number - - meter_register?: MeterRegister - meter_type?: MeterType - status?: MeterStatus - well?: Well - location?: Location -} - -export interface MeterListDTO { - id: number - serial_number: string - status?: { status_name?: string } - water_users: string - location: { - trss: string - longitude: number - latitude: number - } - well: { - ra_number: string - name: string - owners: string - } -} - -interface WellListQueryParams { - search_string?: string - // sort_by?: WellSortByField - sort_direction?: SortDirection - limit?: number - offset?: number - exclude_inactive?: boolean -} - -export interface Page { - items: T[] - total: number - limit: number - offset: number -} - -export interface MeterListQuery { - search_string: string - sort_by: MeterListSortBy - sort_direction: SortDirection - limit: number, - offset: number -} - -// Single manual measurement from a certain well -export interface WellMeasurementDTO { - id: number - timestamp: Date - value: number - submitting_user: { full_name: string } - well: { id: number, ra_number: string } -} - -export interface RegionMeasurementDTO { - id: number - timestamp: Date - value: number - submitting_user: { id: number, full_name: string } - well: { id: number, ra_number: string } -} - -// Single value from a NM ST2 endpoint, many other fields are returned, these are the only ones used at the moment -export interface ST2Measurement { - result: number - resultTime: Date - phenomenonTime: Date -} - -// Whole response returned from a NM ST2 endpoint -export interface ST2Response { - "@iot.nextLink": string - value: [] -} - -// The object that gets sent to the backend to add a new measurement -export interface NewWellMeasurement { - well_id: number - timestamp: string - value: number - submitting_user_id: number -} - -export interface PatchWellMeasurement { - levelmeasurement_id: number - submitting_user_id: number - timestamp: dayjs.Dayjs - value: number -} - -export interface NewRegionMeasurement { - region_id: number - timestamp: string - value?: number | null - submitting_user_id: number - well_id: number -} - -export interface PatchRegionMeasurement { - levelmeasurement_id: number - submitting_user_id: number - well_id: number - timestamp: dayjs.Dayjs - value?: number | null -} - -export interface CreateUser { - username: string - full_name: string - email: scope_string - disabled: boolean - user_role: { id: number } - password: string -} - -export interface UpdatedUserPassword { - user_id: number - new_password: string -} - -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 -} - -export interface NewUser { - id: number - username: string - full_name: string - email: scope_string - disabled: boolean - user_role_id: number - password: string -} - -export interface UserRole { - id: number - name: string - security_scopes: SecurityScope[] -} - -export interface SecurityScope { - id: number - scope_string: string - description: string -} - -export interface WorkOrder { - work_order_id: number - date_created: Date - creator?: String - meter_serial: String - title: String - description: String - status: String - notes?: String - assigned_user_id?: number - assigned_user?: String - associated_activities?: number[] -} - -export interface NewWorkOrder { - //Just the bare minimum to create a new work order - //No work order ID since it is generated by the backend - date_created: Date //This should be on the frontend to ensure it doesn't reflect server time - meter_id: number - title: string -} - -export interface PatchWorkOrder { - // This is designed to match the backend API patch endpoint and is limited to the fields that can be updated - work_order_id: number - title?: string - description?: string - status?: string - notes?: string - assigned_user_id?: number -} - -export interface MonitoredWell { - id: number; - name: string; - ra_number: string; - datastream_id: number; - well_status: WellStatus; - outside_recorder?: boolean; - chloride_group_id?: number; -} - -export interface MonitoredRegion { - id: number; - name: string; - datastream_id: number; - well_status: WellStatus; - outside_recorder?: boolean; -} diff --git a/frontend/src/interfaces/ActivityForm.ts b/frontend/src/interfaces/ActivityForm.ts new file mode 100644 index 00000000..94e55bdb --- /dev/null +++ b/frontend/src/interfaces/ActivityForm.ts @@ -0,0 +1,38 @@ +import type { Dayjs } from "dayjs"; +import type { ObservationForm } from "./ObservationForm"; + +export interface ActivityForm { + activity_details?: { + meter_id?: number; + activity_type_id?: number; + user_id?: number; + date?: Dayjs; + start_time?: Dayjs; + end_time?: Dayjs; + share_ose: boolean; + work_order_id?: number; + }; + + current_installation?: { + contact_name?: string; + contact_phone?: string; + well_id?: number; + notes?: string; + water_users?: string; + meter_owner?: string; + }; + + observations?: ObservationForm[]; + + maintenance_repair?: { + service_type_ids: number[]; + description: string; + }; + + notes?: { + working_on_arrival_slug: string; + selected_note_ids: number[]; + }; + + part_used_ids?: number[]; +} diff --git a/frontend/src/interfaces/ActivityFormControl.ts b/frontend/src/interfaces/ActivityFormControl.ts new file mode 100644 index 00000000..b09886b6 --- /dev/null +++ b/frontend/src/interfaces/ActivityFormControl.ts @@ -0,0 +1,41 @@ +import type { Dayjs } from "dayjs"; +import type { ActivityTypeLU } from "./ActivityTypeLU"; +import type { MeterDetails } from "./MeterDetails"; +import type { MeterListDTO } from "./MeterListDTO"; +import type { User } from "./User"; +import type { Well } from "./Well"; + +// This might could be the full things that are selected, but for now its only the things that are submitted/validated +// These need to be the actual interfaces eventually, meter -> MeterListDTO +export interface ActivityFormControl { + activity_details: { + selected_meter: Partial | null; + activity_type: Partial | null; + user: Partial | null; + date: Dayjs; + start_time: Dayjs; + end_time: Dayjs; + share_ose: boolean; + work_order_id: number | null; + }; + current_installation: { + meter: Partial | null; + well: Partial | null; + }; + observations: Array<{ + time: Dayjs; + reading: "" | number; + property_type_id: number | null; + unit_id: number | null; + }>; + maintenance_repair?: { + service_type_ids: number[] | null; + description: string; + }; + notes: { + working_on_arrival_slug: string; + selected_note_ids: number[] | null; + }; + photos?: File[]; + part_used_ids?: []; +} diff --git a/frontend/src/interfaces/ActivityTypeLU.ts b/frontend/src/interfaces/ActivityTypeLU.ts new file mode 100644 index 00000000..4755c359 --- /dev/null +++ b/frontend/src/interfaces/ActivityTypeLU.ts @@ -0,0 +1,6 @@ +export interface ActivityTypeLU { + id: number; + name: string; + description: string; + permission: string; +} diff --git a/frontend/src/interfaces/BaseWell.ts b/frontend/src/interfaces/BaseWell.ts new file mode 100644 index 00000000..e12efce7 --- /dev/null +++ b/frontend/src/interfaces/BaseWell.ts @@ -0,0 +1,15 @@ +export interface BaseWell { + id: number; + name: string; + ra_number: string; + owners: string; + osetag: string; + casing: string; + total_depth: number; + outside_recorder: boolean; + location_id: number; + use_type_id: number; + well_status_id: number; + water_source_id: number; + chloride_group_id: number | null; +} diff --git a/frontend/src/interfaces/CreateUser.ts b/frontend/src/interfaces/CreateUser.ts new file mode 100644 index 00000000..5b35518d --- /dev/null +++ b/frontend/src/interfaces/CreateUser.ts @@ -0,0 +1,10 @@ +import type { scope_string } from "./primitives"; + +export interface CreateUser { + username: string; + full_name: string; + email: scope_string; + disabled: boolean; + user_role: { id: number }; + password: string; +} diff --git a/frontend/src/interfaces/LandOwner.ts b/frontend/src/interfaces/LandOwner.ts new file mode 100644 index 00000000..611ceced --- /dev/null +++ b/frontend/src/interfaces/LandOwner.ts @@ -0,0 +1,9 @@ +export interface LandOwner { + id: number; + contact_name?: string; + land_owner_name?: string; + organization?: string; + phone?: string; + email?: string; + city?: string; +} diff --git a/frontend/src/interfaces/Location.ts b/frontend/src/interfaces/Location.ts new file mode 100644 index 00000000..0d6f938d --- /dev/null +++ b/frontend/src/interfaces/Location.ts @@ -0,0 +1,12 @@ +import type { float } from "./primitives"; +import type { LandOwner } from "./LandOwner"; + +export interface Location { + name: string; + latitude: float; + longitude: float; + trss: string; + land_owner_id: number; + + land_owner?: LandOwner; +} diff --git a/frontend/src/interfaces/Meter.ts b/frontend/src/interfaces/Meter.ts new file mode 100644 index 00000000..10d9aa9f --- /dev/null +++ b/frontend/src/interfaces/Meter.ts @@ -0,0 +1,25 @@ +import type { Location } from "./Location"; +import type { MeterRegister } from "./MeterRegister"; +import type { MeterStatus } from "./MeterStatus"; +import type { MeterType } from "./MeterType"; +import type { Well } from "./Well"; + +export interface Meter { + id: number; + serial_number: string; + contact_name?: string; + contact_phone?: string; + notes?: string; + price?: number; + + meter_type_id: number; + status_id?: number; + well_id: number; + location_id?: number; + + meter_register?: MeterRegister; + meter_type?: MeterType; + status?: MeterStatus; + well?: Well; + location?: Location; +} diff --git a/frontend/src/interfaces/MeterActivity.ts b/frontend/src/interfaces/MeterActivity.ts new file mode 100644 index 00000000..6316bbf8 --- /dev/null +++ b/frontend/src/interfaces/MeterActivity.ts @@ -0,0 +1,22 @@ +import type { int } from "./primitives"; +import type { ActivityTypeLU } from "./ActivityTypeLU"; +import type { Location } from "./Location"; +import type { Meter } from "./Meter"; +import type { User } from "./User"; + +export interface MeterActivity { + id: int; + timestamp_start: Date; + timestamp_end: Date; + notes?: string; + submitting_user_id: int; + meter_id: int; + activity_type_id: int; + location_id: int; + + submitting_user?: User; + meter?: Meter; + activity_type?: ActivityTypeLU; + location?: Location; + parts_used?: []; +} diff --git a/frontend/src/interfaces/MeterDetails.ts b/frontend/src/interfaces/MeterDetails.ts new file mode 100644 index 00000000..49266399 --- /dev/null +++ b/frontend/src/interfaces/MeterDetails.ts @@ -0,0 +1,26 @@ +import type { float, int } from "./primitives"; +import type { MeterRegister } from "./MeterRegister"; +import type { MeterStatus } from "./MeterStatus"; +import type { MeterType } from "./MeterType"; +import type { Well } from "./Well"; + +export interface MeterDetails { + id?: number | null; + serial_number?: string | null; + contact_name?: string | null; + contact_phone?: string | null; + water_users?: string | null; + meter_owner?: string | null; + ra_number?: string | null; + tag?: string | null; + well_distance_ft?: float | null; + notes?: string | null; + meter_type_id?: int | null; + well_id?: int | null; + + meter_type: MeterType; + status: MeterStatus; + well: Well | null; + meter_register: MeterRegister | null; + // Also has parts_associated?: List[Part] +} diff --git a/frontend/src/interfaces/MeterDetailsQueryParams.ts b/frontend/src/interfaces/MeterDetailsQueryParams.ts new file mode 100644 index 00000000..39b33bfc --- /dev/null +++ b/frontend/src/interfaces/MeterDetailsQueryParams.ts @@ -0,0 +1,3 @@ +export interface MeterDetailsQueryParams { + meter_id: number | undefined; +} diff --git a/frontend/src/interfaces/MeterHistoryDTO.ts b/frontend/src/interfaces/MeterHistoryDTO.ts new file mode 100644 index 00000000..ec55c068 --- /dev/null +++ b/frontend/src/interfaces/MeterHistoryDTO.ts @@ -0,0 +1,14 @@ +import type { int } from "./primitives"; +import type { Location } from "./Location"; +import type { Well } from "./Well"; + +export interface MeterHistoryDTO { + id: int; + history_type: string; + activity_type: string; + date: Date; + history_item: any; + location: Location; + well: Well | null; + photos: any; +} diff --git a/frontend/src/interfaces/MeterListDTO.ts b/frontend/src/interfaces/MeterListDTO.ts new file mode 100644 index 00000000..30cbb5eb --- /dev/null +++ b/frontend/src/interfaces/MeterListDTO.ts @@ -0,0 +1,16 @@ +export interface MeterListDTO { + id: number; + serial_number: string; + status?: { status_name?: string }; + water_users: string; + location: { + trss: string; + longitude: number; + latitude: number; + }; + well: { + ra_number: string; + name: string; + owners: string; + }; +} diff --git a/frontend/src/interfaces/MeterListQuery.ts b/frontend/src/interfaces/MeterListQuery.ts new file mode 100644 index 00000000..20ad11c2 --- /dev/null +++ b/frontend/src/interfaces/MeterListQuery.ts @@ -0,0 +1,10 @@ +import type { SortDirection } from "@/enums"; +import type { MeterListSortBy } from "./MeterListSortBy"; + +export interface MeterListQuery { + search_string: string; + sort_by: MeterListSortBy; + sort_direction: SortDirection; + limit: number; + offset: number; +} diff --git a/frontend/src/interfaces/MeterListQueryParams.ts b/frontend/src/interfaces/MeterListQueryParams.ts new file mode 100644 index 00000000..46056f8e --- /dev/null +++ b/frontend/src/interfaces/MeterListQueryParams.ts @@ -0,0 +1,10 @@ +import type { MeterSortByField, MeterStatusNames, SortDirection } from "@/enums"; + +export interface MeterListQueryParams { + search_string?: string; + filter_by_status?: MeterStatusNames[]; + sort_by?: MeterSortByField; + sort_direction?: SortDirection; + limit?: number; + offset?: number; +} diff --git a/frontend/src/interfaces/MeterListSortBy.ts b/frontend/src/interfaces/MeterListSortBy.ts new file mode 100644 index 00000000..683204b6 --- /dev/null +++ b/frontend/src/interfaces/MeterListSortBy.ts @@ -0,0 +1,3 @@ +import type { MeterSortByField } from "@/enums"; + +export type MeterListSortBy = MeterSortByField; diff --git a/frontend/src/interfaces/MeterMapDTO.ts b/frontend/src/interfaces/MeterMapDTO.ts new file mode 100644 index 00000000..55b97326 --- /dev/null +++ b/frontend/src/interfaces/MeterMapDTO.ts @@ -0,0 +1,13 @@ +export interface MeterMapDTO { + id: number; + serial_number: string; + well: { + ra_number: string; + name: string; + }; + location: { + longitude: number; + latitude: number; + }; + last_pm: string; +} diff --git a/frontend/src/interfaces/MeterPartParams.ts b/frontend/src/interfaces/MeterPartParams.ts new file mode 100644 index 00000000..18368956 --- /dev/null +++ b/frontend/src/interfaces/MeterPartParams.ts @@ -0,0 +1,3 @@ +export interface MeterPartParams { + meter_id: number | undefined; +} diff --git a/frontend/src/interfaces/MeterRegister.ts b/frontend/src/interfaces/MeterRegister.ts new file mode 100644 index 00000000..24873aca --- /dev/null +++ b/frontend/src/interfaces/MeterRegister.ts @@ -0,0 +1,13 @@ +import type { Unit } from "./Unit"; + +export interface MeterRegister { + id: number; + brand: string; + meter_size: number; + ratio: string | null; + number_of_digits: number | null; + decimal_digits: number | null; + dial_units: Unit; + totalizer_units: Unit; + multiplier?: number | null; +} diff --git a/frontend/src/interfaces/MeterStatus.ts b/frontend/src/interfaces/MeterStatus.ts new file mode 100644 index 00000000..1294633d --- /dev/null +++ b/frontend/src/interfaces/MeterStatus.ts @@ -0,0 +1,5 @@ +export interface MeterStatus { + id: number; + status_name?: string; + description?: string; +} diff --git a/frontend/src/interfaces/MeterType.ts b/frontend/src/interfaces/MeterType.ts new file mode 100644 index 00000000..1f84b725 --- /dev/null +++ b/frontend/src/interfaces/MeterType.ts @@ -0,0 +1,10 @@ +import type { float, int } from "./primitives"; + +export interface MeterType { + id?: int; + brand?: string; + series?: string; + model?: string; + size?: float; + description?: string; +} diff --git a/frontend/src/interfaces/MeterTypeLU.ts b/frontend/src/interfaces/MeterTypeLU.ts new file mode 100644 index 00000000..92cd86c5 --- /dev/null +++ b/frontend/src/interfaces/MeterTypeLU.ts @@ -0,0 +1,10 @@ +//Depricate this??? need to assess +export interface MeterTypeLU { + id: number; + brand: string; + series: string; + model: string; + size: number; + description: string; + in_use: boolean; +} diff --git a/frontend/src/interfaces/MonitoredRegion.ts b/frontend/src/interfaces/MonitoredRegion.ts new file mode 100644 index 00000000..cd74b0be --- /dev/null +++ b/frontend/src/interfaces/MonitoredRegion.ts @@ -0,0 +1,9 @@ +import type { WellStatus } from "./WellStatus"; + +export interface MonitoredRegion { + id: number; + name: string; + datastream_id: number; + well_status: WellStatus; + outside_recorder?: boolean; +} diff --git a/frontend/src/interfaces/MonitoredWell.ts b/frontend/src/interfaces/MonitoredWell.ts new file mode 100644 index 00000000..4ffabb3f --- /dev/null +++ b/frontend/src/interfaces/MonitoredWell.ts @@ -0,0 +1,11 @@ +import type { WellStatus } from "./WellStatus"; + +export interface MonitoredWell { + id: number; + name: string; + ra_number: string; + datastream_id: number; + well_status: WellStatus; + outside_recorder?: boolean; + chloride_group_id?: number; +} diff --git a/frontend/src/interfaces/NewRegionMeasurement.ts b/frontend/src/interfaces/NewRegionMeasurement.ts new file mode 100644 index 00000000..5bccb7c8 --- /dev/null +++ b/frontend/src/interfaces/NewRegionMeasurement.ts @@ -0,0 +1,7 @@ +export interface NewRegionMeasurement { + region_id: number; + timestamp: string; + value?: number | null; + submitting_user_id: number; + well_id: number; +} diff --git a/frontend/src/interfaces/NewUser.ts b/frontend/src/interfaces/NewUser.ts new file mode 100644 index 00000000..398b1c23 --- /dev/null +++ b/frontend/src/interfaces/NewUser.ts @@ -0,0 +1,11 @@ +import type { scope_string } from "./primitives"; + +export interface NewUser { + id: number; + username: string; + full_name: string; + email: scope_string; + disabled: boolean; + user_role_id: number; + password: string; +} diff --git a/frontend/src/interfaces/NewWellMeasurement.ts b/frontend/src/interfaces/NewWellMeasurement.ts new file mode 100644 index 00000000..7d7abf0e --- /dev/null +++ b/frontend/src/interfaces/NewWellMeasurement.ts @@ -0,0 +1,7 @@ +// The object that gets sent to the backend to add a new measurement +export interface NewWellMeasurement { + well_id: number; + timestamp: string; + value: number; + submitting_user_id: number; +} diff --git a/frontend/src/interfaces/NewWorkOrder.ts b/frontend/src/interfaces/NewWorkOrder.ts new file mode 100644 index 00000000..12966c66 --- /dev/null +++ b/frontend/src/interfaces/NewWorkOrder.ts @@ -0,0 +1,7 @@ +//Just the bare minimum to create a new work order +//No work order ID since it is generated by the backend +export interface NewWorkOrder { + date_created: Date; //This should be on the frontend to ensure it doesn't reflect server time + meter_id: number; + title: string; +} diff --git a/frontend/src/interfaces/NoteTypeLU.ts b/frontend/src/interfaces/NoteTypeLU.ts new file mode 100644 index 00000000..2332536e --- /dev/null +++ b/frontend/src/interfaces/NoteTypeLU.ts @@ -0,0 +1,7 @@ +export interface NoteTypeLU { + id: number; + note: string; + details?: string; + slug?: string; + commonly_used: boolean; +} diff --git a/frontend/src/interfaces/ObservationForm.ts b/frontend/src/interfaces/ObservationForm.ts new file mode 100644 index 00000000..f809814c --- /dev/null +++ b/frontend/src/interfaces/ObservationForm.ts @@ -0,0 +1,8 @@ +import type { Dayjs } from "dayjs"; + +export interface ObservationForm { + time: Dayjs; + reading: "" | number; + property_type_id: "" | number; + unit_id: "" | number; +} diff --git a/frontend/src/interfaces/ObservedPropertyTypeLU.ts b/frontend/src/interfaces/ObservedPropertyTypeLU.ts new file mode 100644 index 00000000..746a1539 --- /dev/null +++ b/frontend/src/interfaces/ObservedPropertyTypeLU.ts @@ -0,0 +1,10 @@ +import type { Unit } from "./Unit"; + +export interface ObservedPropertyTypeLU { + id: number; + name: string; + description: string; + context: string; + + units?: Unit[]; +} diff --git a/frontend/src/interfaces/Organization.ts b/frontend/src/interfaces/Organization.ts new file mode 100644 index 00000000..332471ce --- /dev/null +++ b/frontend/src/interfaces/Organization.ts @@ -0,0 +1,3 @@ +export interface Organization { + organization_name: string; +} diff --git a/frontend/src/interfaces/Page.ts b/frontend/src/interfaces/Page.ts new file mode 100644 index 00000000..f1b1946e --- /dev/null +++ b/frontend/src/interfaces/Page.ts @@ -0,0 +1,6 @@ +export interface Page { + items: T[]; + total: number; + limit: number; + offset: number; +} diff --git a/frontend/src/interfaces/Part.ts b/frontend/src/interfaces/Part.ts new file mode 100644 index 00000000..f2251520 --- /dev/null +++ b/frontend/src/interfaces/Part.ts @@ -0,0 +1,18 @@ +import type { MeterTypeLU } from "./MeterTypeLU"; +import type { PartTypeLU } from "./PartTypeLU"; + +export interface Part { + id: number; + part_number: string; + part_type_id: number; + vendor?: string; + note?: string; + description?: string; + initial_count?: number; + current_count?: number; + in_use: boolean; + commonly_used: boolean; + + part_type?: PartTypeLU; + meter_types?: MeterTypeLU[]; +} diff --git a/frontend/src/interfaces/PartAssociation.ts b/frontend/src/interfaces/PartAssociation.ts new file mode 100644 index 00000000..e81db279 --- /dev/null +++ b/frontend/src/interfaces/PartAssociation.ts @@ -0,0 +1,10 @@ +import type { int } from "./primitives"; +import type { Part } from "./Part"; + +export interface PartAssociation { + id: int; + meter_type_id: int; + part_id: int; + commonly_used: boolean; + part?: Part; +} diff --git a/frontend/src/interfaces/PartTypeLU.ts b/frontend/src/interfaces/PartTypeLU.ts new file mode 100644 index 00000000..b92a9cf2 --- /dev/null +++ b/frontend/src/interfaces/PartTypeLU.ts @@ -0,0 +1,7 @@ +import type { int } from "./primitives"; + +export interface PartTypeLU { + id: int; + name: string; + description?: string; +} diff --git a/frontend/src/interfaces/PatchActivityForm.ts b/frontend/src/interfaces/PatchActivityForm.ts new file mode 100644 index 00000000..8e89456e --- /dev/null +++ b/frontend/src/interfaces/PatchActivityForm.ts @@ -0,0 +1,29 @@ +import type { Dayjs } from "dayjs"; +import type { int } from "./primitives"; +import type { ActivityTypeLU } from "./ActivityTypeLU"; +import type { NoteTypeLU } from "./NoteTypeLU"; +import type { Part } from "./Part"; +import type { ServiceTypeLU } from "./ServiceTypeLU"; +import type { User } from "./User"; +import type { Well } from "./Well"; + +//This is designed to match the HistoryDetails form rather than the patch meter API +export interface PatchActivityForm { + activity_id: int; + meter_id: int; + activity_date: Dayjs; + activity_start_time: Dayjs; + activity_end_time: Dayjs; + activity_type: ActivityTypeLU; + submitting_user: User; + description: string; + + well: Well | null; + water_users?: string; + + notes?: NoteTypeLU[]; + services?: ServiceTypeLU[]; + parts_used?: Part[]; + + ose_share: boolean; +} diff --git a/frontend/src/interfaces/PatchActivitySubmit.ts b/frontend/src/interfaces/PatchActivitySubmit.ts new file mode 100644 index 00000000..211019bc --- /dev/null +++ b/frontend/src/interfaces/PatchActivitySubmit.ts @@ -0,0 +1,19 @@ +import type { int } from "./primitives"; + +//This interface is designed to match the backend API patch endpoint +export interface PatchActivitySubmit { + activity_id: int; + timestamp_start: string; + timestamp_end: string; + description: string; + submitting_user_id: int; + meter_id: int; + activity_type_id: int; + location_id: int | null; + ose_share: boolean; + water_users: string; + + note_ids: int[] | null; + service_ids: int[] | null; + part_ids: int[] | null; +} diff --git a/frontend/src/interfaces/PatchObservationForm.ts b/frontend/src/interfaces/PatchObservationForm.ts new file mode 100644 index 00000000..dc5ada10 --- /dev/null +++ b/frontend/src/interfaces/PatchObservationForm.ts @@ -0,0 +1,21 @@ +import type { Dayjs } from "dayjs"; +import type { int } from "./primitives"; +import type { ObservedPropertyTypeLU } from "./ObservedPropertyTypeLU"; +import type { Unit } from "./Unit"; +import type { User } from "./User"; +import type { Well } from "./Well"; + +//Designed for the HistoryDetails component, not the patch endpoint +export interface PatchObservationForm { + observation_id: int; + submitting_user: User; + well: Well | null; + observation_date: Dayjs; + observation_time: Dayjs; + property_type: ObservedPropertyTypeLU; + unit: Unit; + value: number; + ose_share: boolean; + notes?: string; + meter_id: int; +} diff --git a/frontend/src/interfaces/PatchObservationSubmit.ts b/frontend/src/interfaces/PatchObservationSubmit.ts new file mode 100644 index 00000000..f2b24d7f --- /dev/null +++ b/frontend/src/interfaces/PatchObservationSubmit.ts @@ -0,0 +1,15 @@ +import type { int } from "./primitives"; + +export interface PatchObservationSubmit { + //Matches the backend API patch endpoint + observation_id: int; + timestamp: string; + value: number; + notes: string | null; + submitting_user_id: int; + meter_id: int; + observed_property_type_id: int; + unit_id: int; + location_id: int | null; + ose_share: boolean; +} diff --git a/frontend/src/interfaces/PatchRegionMeasurement.ts b/frontend/src/interfaces/PatchRegionMeasurement.ts new file mode 100644 index 00000000..9f9c8f68 --- /dev/null +++ b/frontend/src/interfaces/PatchRegionMeasurement.ts @@ -0,0 +1,9 @@ +import type { Dayjs } from "dayjs"; + +export interface PatchRegionMeasurement { + levelmeasurement_id: number; + submitting_user_id: number; + well_id: number; + timestamp: Dayjs; + value?: number | null; +} diff --git a/frontend/src/interfaces/PatchWellMeasurement.ts b/frontend/src/interfaces/PatchWellMeasurement.ts new file mode 100644 index 00000000..c567ec45 --- /dev/null +++ b/frontend/src/interfaces/PatchWellMeasurement.ts @@ -0,0 +1,8 @@ +import type { Dayjs } from "dayjs"; + +export interface PatchWellMeasurement { + levelmeasurement_id: number; + submitting_user_id: number; + timestamp: Dayjs; + value: number; +} diff --git a/frontend/src/interfaces/PatchWorkOrder.ts b/frontend/src/interfaces/PatchWorkOrder.ts new file mode 100644 index 00000000..92b6c959 --- /dev/null +++ b/frontend/src/interfaces/PatchWorkOrder.ts @@ -0,0 +1,9 @@ +// This is designed to match the backend API patch endpoint and is limited to the fields that can be updated +export interface PatchWorkOrder { + work_order_id: number; + title?: string; + description?: string; + status?: string; + notes?: string; + assigned_user_id?: number; +} diff --git a/frontend/src/interfaces/RegionMeasurementDTO.ts b/frontend/src/interfaces/RegionMeasurementDTO.ts new file mode 100644 index 00000000..5ddafc4c --- /dev/null +++ b/frontend/src/interfaces/RegionMeasurementDTO.ts @@ -0,0 +1,7 @@ +export interface RegionMeasurementDTO { + id: number; + timestamp: Date; + value: number; + submitting_user: { id: number; full_name: string }; + well: { id: number; ra_number: string }; +} diff --git a/frontend/src/interfaces/ST2Measurement.ts b/frontend/src/interfaces/ST2Measurement.ts new file mode 100644 index 00000000..9e020e0c --- /dev/null +++ b/frontend/src/interfaces/ST2Measurement.ts @@ -0,0 +1,6 @@ +// Single value from a NM ST2 endpoint, many other fields are returned, these are the only ones used at the moment +export interface ST2Measurement { + result: number; + resultTime: Date; + phenomenonTime: Date; +} diff --git a/frontend/src/interfaces/ST2Response.ts b/frontend/src/interfaces/ST2Response.ts new file mode 100644 index 00000000..689b51c9 --- /dev/null +++ b/frontend/src/interfaces/ST2Response.ts @@ -0,0 +1,5 @@ +// Whole response returned from a NM ST2 endpoint +export interface ST2Response { + "@iot.nextLink": string; + value: []; +} diff --git a/frontend/src/interfaces/ST2WaterLevelQueryParams.ts b/frontend/src/interfaces/ST2WaterLevelQueryParams.ts new file mode 100644 index 00000000..11c9f15b --- /dev/null +++ b/frontend/src/interfaces/ST2WaterLevelQueryParams.ts @@ -0,0 +1,5 @@ +export interface ST2WaterLevelQueryParams { + $filter: string; + $orderby: string; + datastreamID: number | undefined; +} diff --git a/frontend/src/interfaces/SecurityScope.ts b/frontend/src/interfaces/SecurityScope.ts new file mode 100644 index 00000000..cf94b789 --- /dev/null +++ b/frontend/src/interfaces/SecurityScope.ts @@ -0,0 +1,5 @@ +export interface SecurityScope { + id: number; + scope_string: string; + description: string; +} diff --git a/frontend/src/interfaces/ServiceTypeLU.ts b/frontend/src/interfaces/ServiceTypeLU.ts new file mode 100644 index 00000000..c1711eb5 --- /dev/null +++ b/frontend/src/interfaces/ServiceTypeLU.ts @@ -0,0 +1,5 @@ +export interface ServiceTypeLU { + id: number; + service_name: string; + description?: string; +} diff --git a/frontend/src/interfaces/SubmitWellCreate.ts b/frontend/src/interfaces/SubmitWellCreate.ts new file mode 100644 index 00000000..f34f8bf5 --- /dev/null +++ b/frontend/src/interfaces/SubmitWellCreate.ts @@ -0,0 +1,22 @@ +import type { float } from "./primitives"; +import type { WaterSource } from "./WaterSource"; + +export interface SubmitWellCreate { + name: string; + ra_number: string; + owners: string; + osetag: string; + water_source: WaterSource | null; + chloride_group_id: number | null; + + use_type: { + id: number; + }; + + location: { + name: string; + trss: string; + longitude: float; + latitude: float; + }; +} diff --git a/frontend/src/interfaces/Unit.ts b/frontend/src/interfaces/Unit.ts new file mode 100644 index 00000000..376a7ed6 --- /dev/null +++ b/frontend/src/interfaces/Unit.ts @@ -0,0 +1,6 @@ +export interface Unit { + id: number; + name: string; + name_short: string; + description: string; +} diff --git a/frontend/src/interfaces/UpdatedUserPassword.ts b/frontend/src/interfaces/UpdatedUserPassword.ts new file mode 100644 index 00000000..2579265e --- /dev/null +++ b/frontend/src/interfaces/UpdatedUserPassword.ts @@ -0,0 +1,4 @@ +export interface UpdatedUserPassword { + user_id: number; + new_password: string; +} diff --git a/frontend/src/interfaces/User.ts b/frontend/src/interfaces/User.ts new file mode 100644 index 00000000..1e3e600f --- /dev/null +++ b/frontend/src/interfaces/User.ts @@ -0,0 +1,14 @@ +import type { scope_string } from "./primitives"; +import type { UserRole } from "./UserRole"; + +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/interfaces/UserRole.ts b/frontend/src/interfaces/UserRole.ts new file mode 100644 index 00000000..eedfae03 --- /dev/null +++ b/frontend/src/interfaces/UserRole.ts @@ -0,0 +1,7 @@ +import type { SecurityScope } from "./SecurityScope"; + +export interface UserRole { + id: number; + name: string; + security_scopes: SecurityScope[]; +} diff --git a/frontend/src/interfaces/WaterLevelQueryParams.ts b/frontend/src/interfaces/WaterLevelQueryParams.ts new file mode 100644 index 00000000..dfa8853d --- /dev/null +++ b/frontend/src/interfaces/WaterLevelQueryParams.ts @@ -0,0 +1,3 @@ +export interface WaterLevelQueryParams { + well_id: number | undefined; +} diff --git a/frontend/src/interfaces/WaterSource.ts b/frontend/src/interfaces/WaterSource.ts new file mode 100644 index 00000000..c31645ec --- /dev/null +++ b/frontend/src/interfaces/WaterSource.ts @@ -0,0 +1,5 @@ +export interface WaterSource { + id: number; + name: string; + description: string; +} diff --git a/frontend/src/interfaces/Well.ts b/frontend/src/interfaces/Well.ts new file mode 100644 index 00000000..f2f29e42 --- /dev/null +++ b/frontend/src/interfaces/Well.ts @@ -0,0 +1,21 @@ +import type { int } from "./primitives"; +import type { BaseWell } from "./BaseWell"; +import type { Location } from "./Location"; +import type { WaterSource } from "./WaterSource"; +import type { WellStatus } from "./WellStatus"; +import type { WellUseLU } from "./WellUseLU"; + +export interface Well extends BaseWell { + use_type: WellUseLU | null; + water_source: WaterSource | null; + location: Location | null; + well_status: WellStatus | null; + + meters: [ + { + id: int; + serial_number: string; + water_users?: string; + } + ]; +} diff --git a/frontend/src/interfaces/WellDetailsQueryParams.ts b/frontend/src/interfaces/WellDetailsQueryParams.ts new file mode 100644 index 00000000..b1cc5caf --- /dev/null +++ b/frontend/src/interfaces/WellDetailsQueryParams.ts @@ -0,0 +1,3 @@ +export interface WellDetailsQueryParams { + well_id: number | undefined; +} diff --git a/frontend/src/interfaces/WellListQueryParams.ts b/frontend/src/interfaces/WellListQueryParams.ts new file mode 100644 index 00000000..433c95ba --- /dev/null +++ b/frontend/src/interfaces/WellListQueryParams.ts @@ -0,0 +1,10 @@ +import type { SortDirection } from "@/enums"; + +export interface WellListQueryParams { + search_string?: string; + // sort_by?: WellSortByField + sort_direction?: SortDirection; + limit?: number; + offset?: number; + exclude_inactive?: boolean; +} diff --git a/frontend/src/interfaces/WellMeasurementDTO.ts b/frontend/src/interfaces/WellMeasurementDTO.ts new file mode 100644 index 00000000..10978769 --- /dev/null +++ b/frontend/src/interfaces/WellMeasurementDTO.ts @@ -0,0 +1,8 @@ +// Single manual measurement from a certain well +export interface WellMeasurementDTO { + id: number; + timestamp: Date; + value: number; + submitting_user: { full_name: string }; + well: { id: number; ra_number: string }; +} diff --git a/frontend/src/interfaces/WellMergeParams.ts b/frontend/src/interfaces/WellMergeParams.ts new file mode 100644 index 00000000..d294bc80 --- /dev/null +++ b/frontend/src/interfaces/WellMergeParams.ts @@ -0,0 +1,4 @@ +export interface WellMergeParams { + merge_well: string; + target_well: string; +} diff --git a/frontend/src/interfaces/WellStatus.ts b/frontend/src/interfaces/WellStatus.ts new file mode 100644 index 00000000..18453d46 --- /dev/null +++ b/frontend/src/interfaces/WellStatus.ts @@ -0,0 +1,5 @@ +export interface WellStatus { + id: number; + status: string; + description: string; +} diff --git a/frontend/src/interfaces/WellUpdate.ts b/frontend/src/interfaces/WellUpdate.ts new file mode 100644 index 00000000..254e08f8 --- /dev/null +++ b/frontend/src/interfaces/WellUpdate.ts @@ -0,0 +1,12 @@ +import type { BaseWell } from "./BaseWell"; +import type { Location } from "./Location"; +import type { WaterSource } from "./WaterSource"; +import type { WellStatus } from "./WellStatus"; +import type { WellUseLU } from "./WellUseLU"; + +export interface WellUpdate extends BaseWell { + use_type: WellUseLU; + water_source: WaterSource; + location: Location; + well_status: WellStatus; +} diff --git a/frontend/src/interfaces/WellUseLU.ts b/frontend/src/interfaces/WellUseLU.ts new file mode 100644 index 00000000..bbf0abef --- /dev/null +++ b/frontend/src/interfaces/WellUseLU.ts @@ -0,0 +1,6 @@ +export interface WellUseLU { + id: number; + use_type: string; + code: string; + description: string; +} diff --git a/frontend/src/interfaces/WorkOrder.ts b/frontend/src/interfaces/WorkOrder.ts new file mode 100644 index 00000000..1fe3f1a1 --- /dev/null +++ b/frontend/src/interfaces/WorkOrder.ts @@ -0,0 +1,13 @@ +export interface WorkOrder { + work_order_id: number; + date_created: Date; + creator?: String; + meter_serial: String; + title: String; + description: String; + status: String; + notes?: String; + assigned_user_id?: number; + assigned_user?: String; + associated_activities?: number[]; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 5262ecb7..21fce508 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -1,8 +1,74 @@ +export * from "./ActivityForm"; +export * from "./ActivityFormControl"; +export * from "./ActivityTypeLU"; export * from "./BackupRow"; +export * from "./BaseWell"; +export * from "./CreateUser"; export * from "./DeviceAttributes"; export * from "./DevicePayload"; export * from "./IncreaseQuantityPayload"; +export * from "./LandOwner"; +export * from "./Location"; export * from "./Measurement"; +export * from "./Meter"; +export * from "./MeterActivity"; +export * from "./MeterDetails"; +export * from "./MeterDetailsQueryParams"; +export * from "./MeterHistoryDTO"; +export * from "./MeterListDTO"; +export * from "./MeterListQuery"; +export * from "./MeterListQueryParams"; +export * from "./MeterListSortBy"; +export * from "./MeterMapDTO"; +export * from "./MeterPartParams"; +export * from "./MeterRegister"; +export * from "./MeterStatus"; +export * from "./MeterType"; +export * from "./MeterTypeLU"; +export * from "./MonitoredRegion"; +export * from "./MonitoredWell"; +export * from "./NewRegionMeasurement"; +export * from "./NewUser"; +export * from "./NewWellMeasurement"; +export * from "./NewWorkOrder"; +export * from "./NoteTypeLU"; +export * from "./ObservationForm"; +export * from "./ObservedPropertyTypeLU"; +export * from "./Organization"; +export * from "./Page"; +export * from "./Part"; +export * from "./PartAssociation"; +export * from "./PartTypeLU"; +export * from "./PatchActivityForm"; +export * from "./PatchActivitySubmit"; +export * from "./PatchObservationForm"; +export * from "./PatchObservationSubmit"; +export * from "./PatchRegionMeasurement"; +export * from "./PatchWellMeasurement"; +export * from "./PatchWorkOrder"; +export * from "./RegionMeasurementDTO"; export * from "./ReportAveragesResponse"; +export * from "./ST2Measurement"; +export * from "./ST2Response"; +export * from "./ST2WaterLevelQueryParams"; +export * from "./SecurityScope"; export * from "./SensorAttributes"; export * from "./SensorData"; +export * from "./ServiceTypeLU"; +export * from "./SubmitWellCreate"; +export * from "./Unit"; +export * from "./UpdatedUserPassword"; +export * from "./User"; +export * from "./UserRole"; +export * from "./WaterLevelQueryParams"; +export * from "./WaterSource"; +export * from "./Well"; +export * from "./WellDetailsQueryParams"; +export * from "./WellListQueryParams"; +export * from "./WellMeasurementDTO"; +export * from "./WellMergeParams"; +export * from "./WellStatus"; +export * from "./WellUpdate"; +export * from "./WellUseLU"; +export * from "./WorkOrder"; +export * from "./primitives"; diff --git a/frontend/src/interfaces/primitives.ts b/frontend/src/interfaces/primitives.ts new file mode 100644 index 00000000..badf9893 --- /dev/null +++ b/frontend/src/interfaces/primitives.ts @@ -0,0 +1,3 @@ +export type int = number; +export type float = number; +export type scope_string = string; diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index de897111..fc6b0f43 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -51,7 +51,7 @@ import { WaterSource, WellStatus, } from "@/interfaces"; -import { IncreaseQuantityPayload } from "@/interfaces/IncreaseQuantityPayload"; +import { IncreaseQuantityPayload } from "@/interfaces"; import { WorkOrderStatus } from "@/enums"; import { API_URL } from "@/config"; import { useNavigate } from "react-router-dom"; @@ -1274,9 +1274,14 @@ export function useCreatePart(onSuccess: Function) { return useMutation({ mutationFn: async (part: Part) => { try { - //Due to the way the form gets generated for a new part, I need to populate part_type_id manually here + if (!part.part_type?.id) { + throw new Error("part_type_id is required but missing"); + } + + // Due to the way the form gets generated for a new part, + // I need to populate part_type_id manually here part.part_type_id = part.part_type?.id; - console.log(part); + const response = await POSTFetch(route, part, authHeader()); if (!response.ok) { diff --git a/frontend/src/utils/AssertDefined.ts b/frontend/src/utils/AssertDefined.ts new file mode 100644 index 00000000..ad9efe0f --- /dev/null +++ b/frontend/src/utils/AssertDefined.ts @@ -0,0 +1,8 @@ +export function assertDefined( + value: T, + message = "Value is required", +): asserts value is NonNullable { + if (value === undefined || value === null) { + throw new Error(message); + } +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 61944980..e2601704 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./AssertDefined"; export * from "./DateUtils"; export * from "./DataStreamUtils"; export * from "./EmptyToNull"; diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index ee18d103..508d45be 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -13,12 +13,6 @@ import { TableHead, TableRow, } from "@mui/material"; -import { SecurityScope, Meter } from "@/interfaces"; -import { - useCreateMeter, - useGetMeter, - useUpdateMeter, -} from "../../service/ApiServiceNew"; import * as Yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { @@ -29,6 +23,8 @@ import { ControlledMeterStatusTypeSelect, ControlledMeterRegisterSelect, } from "@/components"; +import { SecurityScope, Meter } from "@/interfaces"; +import { useCreateMeter, useGetMeter, useUpdateMeter } from "@/service"; import { formatLatLong } from "@/conversions"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ @@ -217,11 +213,12 @@ export const MeterDetailsFields = ({ : watch("well")?.location?.trss}
- {watch("well")?.location?.latitude == null + {!watch("well")?.location?.latitude || + !watch("well")?.location?.longitude ? "--" : formatLatLong( - watch("well")?.location?.latitude, - watch("well")?.location?.longitude, + watch("well")?.location?.latitude ?? 0, + watch("well")?.location?.longitude ?? 0, )} diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index 245842e5..9d6a77a6 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -1,9 +1,6 @@ import { useState, useEffect, useMemo } from "react"; import { Box, Card, CardContent, Grid } from "@mui/material"; -import { MeterHistoryTable } from "./MeterHistoryTable"; -import { SelectedActivityDetails } from "./SelectedActivityDetails"; -import { SelectedObservationDetails } from "./SelectedObservationDetails"; -import { SelectedBlankCard } from "./SelectedBlankCard"; +import { ImageOutlined } from "@mui/icons-material"; import { useLocation, useSearchParams } from "react-router-dom"; import { useGetMeterHistory } from "@/service"; import { @@ -12,14 +9,20 @@ import { PatchObservationForm, } from "@/interfaces"; import { MeterHistoryType } from "@/enums"; +import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "@/components"; + import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; -import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "@/components"; -import { ImageOutlined } from "@mui/icons-material"; dayjs.extend(utc); dayjs.extend(timezone); +import { MeterHistoryTable } from "./MeterHistoryTable"; +import { SelectedActivityDetails } from "./SelectedActivityDetails"; +import { SelectedObservationDetails } from "./SelectedObservationDetails"; +import { SelectedBlankCard } from "./SelectedBlankCard"; +import { assertDefined } from "@/utils"; + export const MeterHistory = ({ selectedMeterID, }: { @@ -83,6 +86,11 @@ export const MeterHistory = ({ function convertHistoryActivity( historyItem: MeterHistoryDTO, ): PatchActivityForm { + assertDefined( + selectedMeterID, + "No meter selected (selectedMeterID is undefined)", + ); + let activity_details: PatchActivityForm = { activity_id: historyItem.history_item.id, meter_id: selectedMeterID, diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 2b5b6b5e..250b2a9c 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -27,8 +27,8 @@ export const MeterSelectionTable = ({ useState({ search_string: "", filter_by_status: [MeterStatusNames.Installed], - sort_by: "serial_number", - sort_direction: "asc", + sort_by: MeterSortByField.SerialNumber, + sort_direction: SortDirection.Ascending, limit: 25, offset: 0, }); @@ -80,12 +80,11 @@ export const MeterSelectionTable = ({ const newParams = { search_string: meterSearchQueryDebounced, filter_by_status: meterStatusFilter, - sort_by: gridSortModel - ? gridSortModel[0]?.field - : MeterSortByField.SerialNumber, - sort_direction: gridSortModel - ? gridSortModel[0]?.sort - : SortDirection.Ascending, + sort_by: + (gridSortModel?.[0]?.field as MeterSortByField) ?? + MeterSortByField.SerialNumber, + sort_direction: + (gridSortModel?.[0]?.sort as SortDirection) ?? SortDirection.Ascending, limit: paginationModel.pageSize, offset: paginationModel.page * paginationModel.pageSize, }; diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index f8245ba3..03f2db6b 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -1,12 +1,12 @@ -import { useEffect } from "react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useLocation } from "react-router-dom"; +import { Grid } from "@mui/material"; + import { MeterSelection } from "./MeterSelection/MeterSelection"; import { MeterDetailsFields } from "./MeterDetailsFields"; import { MeterHistory } from "./MeterHistory/MeterHistory"; -import { Grid } from "@mui/material"; -import { BackgroundBox } from "../../components/BackgroundBox"; +import { BackgroundBox } from "@/components"; // Main view for the Meters page // Can pass state to this view to pre-select a meter and meter history using React Router useLocation diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 6bc80848..8c1aa851 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -42,7 +42,7 @@ import { } from "@/components"; import { RedMapIcon, BlackMapIcon } from "@/components/MapIcons"; import { useFetchWithAuth } from "@/hooks"; -import { useGetWellLocations } from "@/service/ApiServiceNew"; +import { useGetWellLocations } from "@/service"; import { Well } from "@/interfaces"; import { WellStatus } from "@/enums"; @@ -363,8 +363,8 @@ export const ChloridesReportView = () => { setSelectedWell(well), diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index 0a2e2848..29de6ada 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -111,9 +111,9 @@ export default function WellSelectionTable({ const newParams = { search_string: wellSearchQueryDebounced, sort_by: gridSortModel?.at(0)?.field ?? WellSortByField.Name, - sort_direction: gridSortModel - ? gridSortModel[0]?.sort - : SortDirection.Ascending, + sort_direction: + (gridSortModel?.at(0)?.sort as SortDirection) ?? + SortDirection.Ascending, limit: paginationModel.pageSize, offset: paginationModel.page * paginationModel.pageSize, }; From fb6dcd93b340879d1a8093048e2b71c14b8589ee Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Feb 2026 10:50:37 -0600 Subject: [PATCH 23/91] refactor(api): rm unused files --- api/__init__.py | 18 --- api/backupdb/restore.sh | 1 - api/config.py | 1 - api/dbsetup.py | 236 ---------------------------------- api/models/main_models.py | 20 --- api/route_util.py | 18 --- api/routes/__init__.py | 1 - api/session.py | 26 ---- api/tests/__init__.py | 17 --- api/tests/test_main.py | 263 -------------------------------------- api/winenv.bat | 11 -- api/winenv.ps1 | 8 -- api/xls_persistence.py | 47 ------- 13 files changed, 667 deletions(-) delete mode 100644 api/backupdb/restore.sh delete mode 100644 api/dbsetup.py delete mode 100644 api/tests/__init__.py delete mode 100644 api/tests/test_main.py delete mode 100644 api/winenv.bat delete mode 100644 api/winenv.ps1 delete mode 100644 api/xls_persistence.py diff --git a/api/__init__.py b/api/__init__.py index 72f778cc..e69de29b 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,18 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== - - -# ============= EOF ============================================= diff --git a/api/backupdb/restore.sh b/api/backupdb/restore.sh deleted file mode 100644 index edfb5c15..00000000 --- a/api/backupdb/restore.sh +++ /dev/null @@ -1 +0,0 @@ -pg_restore -d appdb_local appdb.sql \ No newline at end of file diff --git a/api/config.py b/api/config.py index 879ce00b..a5011828 100644 --- a/api/config.py +++ b/api/config.py @@ -30,4 +30,3 @@ class Settings: settings = Settings() -# ============= EOF ============================================= diff --git a/api/dbsetup.py b/api/dbsetup.py deleted file mode 100644 index f9418045..00000000 --- a/api/dbsetup.py +++ /dev/null @@ -1,236 +0,0 @@ -# # =============================================================================== -# This script builds the database from scratch and so should only be run as needed -# # =============================================================================== - -import os -import api.models -from api.security import get_password_hash -from sqlalchemy import create_engine -from sqlalchemy.sql import text -from api.session import SessionLocal -from .config import settings - -# Set up a connection -SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL -engine = create_engine(SQLALCHEMY_DATABASE_URL) - -print("Setting up the database") -api.models.main_models.Base.metadata.create_all(engine) - -# Add initial users, roles, and scopes -db = SessionLocal() - -SecurityScopes = api.models.security_models.SecurityScopes -UserRoles = api.models.security_models.UserRoles -Users = api.models.security_models.Users - -admin_scope = SecurityScopes(scope_string="admin", description="Admin-specific scope.") -meter_write_scope = SecurityScopes( - scope_string="meter:write", description="Write meters" -) -activities_write_scope = SecurityScopes( - scope_string="activities:write", description="Write activities" -) -well_measurements_write_scope = SecurityScopes( - scope_string="well_measurement:write", - description="Write well measurements, i.e. Water Levels and Chlorides", -) -reports_run_scope = SecurityScopes( - scope_string="reports:run", description="Run reports" -) -read_scope = SecurityScopes(scope_string="read", description="Read all data.") -ose_scope = SecurityScopes(scope_string="ose", description="Scope given to the OSE") - -technician_role = UserRoles( - name="Technician", - security_scopes=[ - read_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - ], -) -admin_role = UserRoles( - name="Admin", - security_scopes=[ - read_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - ose_scope, - admin_scope, - ], -) -ose_role = UserRoles( - name="OSE", - security_scopes=[read_scope, ose_scope], -) - -admin_user = Users( - full_name="NMWDI Admin", - username="nmwdi_admin", - email="johndoe@example.com", - hashed_password=get_password_hash("testthisapp"), - user_role=technician_role, -) - -db.add_all( - [ - admin_scope, - meter_write_scope, - activities_write_scope, - well_measurements_write_scope, - reports_run_scope, - read_scope, - ose_scope, - technician_role, - admin_role, - ose_role, - admin_user, - ] -) - -db.commit() -db.close() - - -# Load seed data from CSV -# Follows - https://stackoverflow.com/questions/31394998/using-sqlalchemy-to-load-csv-file-into-a-database -# Get the psycopg2 connector - enables running of lower level functions -conn = engine.raw_connection() -cursor = conn.cursor() - -with open("../PVACDdb_migration/csv_data/tables/metertypes.csv", "r") as f: - qry = 'COPY "MeterTypeLU"(id,brand,series,model_number,size,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/NoteTypeLU.csv", "r") as f: - qry = 'COPY "NoteTypeLU"(id,note,details,slug) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/ServiceTypeLU.csv", "r") as f: - qry = 'COPY "ServiceTypeLU"(id,service_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/landowners.csv", "r") as f: - qry = 'COPY "LandOwners"(organization,address,city,state,zip,phone,mobile,note,id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/meterstatus.csv", "r") as f: - qry = 'COPY "MeterStatusLU"(id,status_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/observedproperties.csv", "r") as f: - qry = 'COPY "ObservedPropertyTypeLU"(id,name,description,context) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/units.csv", "r") as f: - qry = 'COPY "Units"(id,name,name_short,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/propertyunits.csv", "r") as f: - qry = 'COPY "PropertyUnits"(property_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/locationtypeLU.csv", "r") as f: - qry = 'COPY "LocationTypeLU"(id,type_name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/locations.csv", "r") as f: - qry = 'COPY "Locations"(id,name,type_id,latitude,longitude,trss) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/welluseLU.csv", "r") as f: - qry = 'COPY "WellUseLU"(id,use_type,code,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/wells.csv", "r") as f: - qry = 'COPY "Wells"(id,name,use_type_id,location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/meters.csv", "r") as f: - qry = 'COPY "Meters"(serial_number,meter_type_id,status_id,location_id,well_id,id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/activities.csv", "r") as f: - qry = 'COPY "ActivityTypeLU"(id,name,description,permission) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open( - "../PVACDdb_migration/csv_data/testing/devdata_wellMeasurement.csv", "r" -) as f: - qry = 'COPY "WellMeasurements"(timestamp,value,well_id,observed_property_id,submitting_user_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/parttypeLU.csv", "r") as f: - qry = 'COPY "PartTypeLU"(id,name,description) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/parts.csv", "r") as f: - qry = 'COPY "Parts"(id,part_number,part_type_id,description,count,note) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -with open("../PVACDdb_migration/csv_data/tables/partsassociated.csv", "r") as f: - qry = 'COPY "PartAssociation"(meter_type_id,part_id,commonly_used) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -# Only load the following for local testing -testing = False -if testing: - with open("api/data/testdata_users.csv", "r") as f: - qry = 'COPY "Users"(id, username, full_name, email, hashed_password, disabled, user_role_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - - # with open("api/data/testdata_meterobservations.csv", "r") as f: - # qry = 'COPY "MeterObservations"(timestamp, value, notes, submitting_user_id, meter_id, observed_property_type_id, unit_id, location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - # with open("api/data/testdata_meteractivities.csv", "r") as f: - # qry = 'COPY "MeterActivities"(id, timestamp_start, timestamp_end, notes, submitting_user_id, meter_id, activity_type_id, location_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - # with open("api/data/testdata_partsused.csv", "r") as f: - # qry = 'COPY "PartsUsed"(meter_activity_id, part_id, count) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - # cursor.copy_expert(qry, f) - - with open("api/data/devdata_chloridemeasurements.csv", "r") as f: - qry = 'COPY "WellMeasurements"(timestamp,value,well_id,observed_property_id,submitting_user_id,unit_id) FROM STDIN WITH (FORMAT CSV, HEADER TRUE)' - cursor.copy_expert(qry, f) - -# Create geometries from location lat longs -cursor.execute('update "Locations" set geom = ST_MakePoint(longitude,latitude)') - -conn.commit() -conn.close() - - -# SQL for activity type security scopes if we ever decide to go that route -# INSERT INTO "SecurityScopes" (scope_string, description) -# VALUES -# ('activities:install', 'Submit install activities'), -# ('activities:uninstall', 'Submit install activities'), -# ('activities:general_maintenance', 'Submit general maintenance activities'), -# ('activities:preventative_maintenance', 'Submit preventative maintenance activities'), -# ('activities:repair', 'Submit repair activities'), -# ('activities:rate_meter', 'Submit rate meter activities'), -# ('activities:sell', 'Submit sell activities'), -# ('activities:scrap', 'Submit scrap activities'); - -# INSERT INTO "ScopesRoles" (security_scope_id, user_role_id) -# VALUES -# (7, 2), -# (8, 2), -# (9, 2), -# (10, 2), -# (11, 2), -# (12, 2), -# (13, 2), -# (14, 2), -# (7, 1), -# (8, 1), -# (9, 1), -# (10, 1), -# (11, 1), -# (12, 1); diff --git a/api/models/main_models.py b/api/models/main_models.py index 2fc32019..693e2893 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -33,9 +33,6 @@ class Base(DeclarativeBase): __name__: str -# ---------- Parts/Services/Notes ------------ - - class PartTypeLU(Base): """ The types of parts @@ -162,8 +159,6 @@ class NoteTypeLU(Base): Column("note_type_id", ForeignKey("NoteTypeLU.id"), nullable=False), ) -# --------- Meter Related Tables --------- - class Meters(Base): """ @@ -217,8 +212,6 @@ class MeterTypeLU(Base): description: Mapped[str] = mapped_column(String) in_use: Mapped[bool] = mapped_column(Boolean, nullable=False) - # parts: Mapped[List["Parts"]] = relationship(secondary=PartAssociation) - class MeterStatusLU(Base): """ @@ -375,7 +368,6 @@ class Units(Base): description: Mapped[str] = mapped_column(String) -# Association table that links observed property types and their appropriate units PropertyUnits = Table( "PropertyUnits", Base.metadata, @@ -383,8 +375,6 @@ class Units(Base): Column("unit_id", ForeignKey("Units.id"), nullable=False), ) -# ---------- Other Tables --------------- - class Locations(Base): """ @@ -402,7 +392,6 @@ class Locations(Base): quarter: Mapped[int] = mapped_column(Integer) half_quarter: Mapped[int] = mapped_column(Integer) quarter_quarter: Mapped[int] = mapped_column(Integer) - # geom = mapped_column(Geometry("POINT")) # SQLAlchemy/FastAPI has some issue sending this type_id: Mapped[int] = mapped_column( Integer, ForeignKey("LocationTypeLU.id"), nullable=False @@ -460,9 +449,6 @@ class LandOwners(Base): note: Mapped[str] = mapped_column(String) -# ----------- Security Tables --------------- - - class Users(Base): """ All info about a user of the app @@ -517,9 +503,6 @@ class UserRoles(Base): ) -# ------------ Wells -------------- - - class WellUseLU(Base): """ The type of well @@ -647,9 +630,6 @@ class workOrders(Base): ) ose_request_id: Mapped[int] = mapped_column(Integer, nullable=True) - # Associated Activities - # associated_activities: Mapped[List['MeterActivities']] = relationship("MeterActivities") - meter: Mapped["Meters"] = relationship() status: Mapped["workOrderStatusLU"] = relationship() assigned_user: Mapped["Users"] = relationship() diff --git a/api/route_util.py b/api/route_util.py index f73e5c1c..389c70f2 100644 --- a/api/route_util.py +++ b/api/route_util.py @@ -1,18 +1,3 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== from fastapi import HTTPException from pydantic import BaseModel @@ -53,6 +38,3 @@ def _get(db, table, dbid): raise HTTPException(status_code=404, detail=f"{table}.{dbid} not found") return db_item - - -# ============= EOF ============================================= diff --git a/api/routes/__init__.py b/api/routes/__init__.py index 34d53041..e69de29b 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -1 +0,0 @@ -# =============================================================================== diff --git a/api/session.py b/api/session.py index dbf1df85..fb3c20d8 100644 --- a/api/session.py +++ b/api/session.py @@ -1,18 +1,3 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -21,14 +6,6 @@ SQLALCHEMY_DATABASE_URL = settings.DATABASE_URL engine = create_engine(SQLALCHEMY_DATABASE_URL) -# if you don't want to install postgres or any database, use sqlite, a file system based database, -# uncomment below lines if you would like to use sqlite and comment above 2 lines of SQLALCHEMY_DATABASE_URL AND engine - -# SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db" -# engine = create_engine( -# SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -# ) -print(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -38,6 +15,3 @@ def get_db(): yield db finally: db.close() - - -# ============= EOF ============================================= diff --git a/api/tests/__init__.py b/api/tests/__init__.py deleted file mode 100644 index a6c2e2d1..00000000 --- a/api/tests/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== - -# ============= EOF ============================================= diff --git a/api/tests/test_main.py b/api/tests/test_main.py deleted file mode 100644 index 1b3a1733..00000000 --- a/api/tests/test_main.py +++ /dev/null @@ -1,263 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -import datetime -import os - -import pytest -from fastapi.testclient import TestClient -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from sqlalchemy.event import listen -from sqlite3 import OperationalError - -from api.dbsetup import setup_db -from api.main import app, get_db -from api.mdels.main_models import Base -from api.routes.alerts import write_user -from api.routes.reports import report_user -from api.security import get_current_user -from api.models.security_models import User - -SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" - - -def load_spatialite(dbapi_conn, connection_record): - dbapi_conn.enable_load_extension(True) - try: - dbapi_conn.load_extension("/usr/lib/x86_64-linux-gnu/mod_spatialite.so") - except OperationalError: - dbapi_conn.load_extension("/usr/lib/aarch64-linux-gnu/mod_spatialite.so") - - -engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} -) - -listen(engine, "connect", load_spatialite) - -Base.metadata.create_all(bind=engine) -TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - -def override_get_db(): - try: - db = TestingSessionLocal() - yield db - finally: - db.close() - - -os.environ["POPULATE_DB"] = "true" -setup_db(engine, next(override_get_db())) - - -def override_user(): - return User(disabled=False) - - -app.dependency_overrides[get_db] = override_get_db -app.dependency_overrides[get_current_user] = override_user -client = TestClient(app) - - -def test_read_repair_report(): - response = client.get("/repair_report") - assert response.status_code == 200 - data = response.json() - assert len(data) == 4 - assert data[0]["meter_serial_number"] == "1992-4-1234" - assert data[0]["e_read"] == "E 2412341" - assert data[0]["h2o_read"] == 638.831 - - -def test_read_meters(): - response = client.get("/meters") - assert response.status_code == 200 - data = response.json() - assert data[0]["serial_number"] == "1992-4-1234" - assert data[0]["name"] == "moo" - assert data[1]["name"] == "tor" - assert data[2]["name"] == "hag" - - -def test_patch_alert(): - response = client.patch("/alerts/1", json={"alert": "patched alert"}) - assert response.status_code == 200 - - -def test_read_alerts(): - response = client.get("/alerts") - assert response.status_code == 200 - assert response.json()[0]["alert"] == "patched alert" - assert response.json()[0]["meter_serial_number"] == "1992-4-1234" - assert "open_timestamp" in response.json()[0].keys() - assert response.json()[0]["closed_timestamp"] is None - assert response.json()[0]["active"] - - -def test_patch_alert_closed(): - response = client.patch( - "/alerts/1", json={"closed_timestamp": datetime.datetime.now().isoformat()} - ) - assert response.status_code == 200 - - -def test_read_wells(): - response = client.get("/wells") - assert response.status_code == 200 - assert sorted(response.json()[0].keys()) == [ - "id", - "latitude", - "location", - "longitude", - "name", - "osepod", - "owner_id", - ] - - -# -# -def test_post_meter(): - response = client.post( - "/meters", - json={ - "id": 10, - "name": "foo", - "serial_id": 1234, - "serial_case_diameter": 4, - "serial_year": 1990, - }, - ) - assert response.status_code == 200 - response = client.get("/meters") - assert response.status_code == 200 - assert len(response.json()) == 4 - - -def test_post_alert(): - response = client.post("/alerts", json={"meter_id": 1, "alert": "this is an alert"}) - assert response.status_code == 200 - response = client.get("/alerts") - assert response.status_code == 200 - assert len(response.json()) == 2 - - -def test_read_alert(): - response = client.get("/alerts/1") - assert response.status_code == 200 - - -def test_api_status(): - response = client.get("/api_status") - assert response.status_code == 200 - assert response.json() == {"ok": True} - - -def test_meter_status_lu(): - response = client.get("/meter_status_lu") - assert response.status_code == 200 - - data = response.json() - assert len(data) == 3 - assert data[0]["name"] == "POK" - assert data[0]["description"] == "Pump OK" - - -def test_wellconstruction(): - response = client.get("/wellconstruction/1") - assert response.status_code == 200 - data = response.json() - assert data["id"] == 1 - assert data["casing_diameter"] == 0 - assert data["hole_depth"] == 0 - assert data["well_depth"] == 0 - assert data["screens"] == [{"id": 1, "top": 10, "bottom": 20}] - - -def test_waterlevels(): - response = client.get("/waterlevels") - assert response.status_code == 200 - - -def test_well_waterlevels(): - response = client.get("/waterlevels?well_id=1") - assert response.status_code == 200 - assert len(response.json()) == 1 - - response = client.get("/waterlevels?well_id=0") - assert response.status_code == 200 - assert len(response.json()) == 0 - - -def test_well_chlorides(): - response = client.get("/chlorides?well_id=1") - assert response.status_code == 200 - assert len(response.json()) == 1 - assert response.json()[0]["value"] == 1234.0 - - response = client.get("/chlorides?well_id=0") - assert response.status_code == 200 - assert len(response.json()) == 0 - - -def test_fuzzy_meter_search(): - response = client.get("/meters?fuzzy_serial=1990") - assert response.status_code == 200 - assert len(response.json()) == 1 - - response = client.get("/meters?fuzzy_owner_name=spen") - assert response.status_code == 200 - data = response.json() - assert len(data) == 1 - assert data[0]["name"] == "tor" - - -def test_fuzzy_well_osepod_search(): - response = client.get("/wells?osepod=1237") - assert response.status_code == 200 - assert len(response.json()) == 1 - - -def test_wells_by_plss(): - response = client.get("/wells?township=100") - assert response.status_code == 200 - assert len(response.json()) == 3 - - response = client.get("/wells?township=100&range_=10") - assert response.status_code == 200 - assert len(response.json()) == 3 - - response = client.get("/wells?township=100&range_=10§ion=4") - assert response.status_code == 200 - assert len(response.json()) == 3 - - response = client.get("/wells?township=100&range_=10§ion=4&quarter=2") - assert response.status_code == 200 - assert len(response.json()) == 1 - - response = client.get("/wells?township=100&half_quarter=1") - assert response.status_code == 200 - assert len(response.json()) == 1 - - -# spatial queries not compatible with spatialite -# def test_read_wells_spatial(): -# response = client.get('/wells?radius=50&latlng=35.4,-105.2') -# assert response.status_code == 200 -# data = response.json() -# assert len(data) == 1 -# ============= EOF ============================================= diff --git a/api/winenv.bat b/api/winenv.bat deleted file mode 100644 index 1a5349c9..00000000 --- a/api/winenv.bat +++ /dev/null @@ -1,11 +0,0 @@ -:: A batch file to quickly set environmental variables -@echo off -set POSTGRES_USER=docker -set POSTGRES_PASSWORD=docker -set POSTGRES_SERVER=db -set POSTGRES_PORT=5432 -set POSTGRES_DB=gis - -:: Uncomment these to initially populate database -set SETUP_DB=1 -set POPULATE_DB=1 \ No newline at end of file diff --git a/api/winenv.ps1 b/api/winenv.ps1 deleted file mode 100644 index 84bbf909..00000000 --- a/api/winenv.ps1 +++ /dev/null @@ -1,8 +0,0 @@ -$Env:POSTGRES_USER='docker' -$Env:POSTGRES_PASSWORD='docker' -$Env:POSTGRES_SERVER='db' -$Env:POSTGRES_PORT='5432' -$Env:POSTGRES_DB='gis' - -$Env:SETUP_DB='1' -$Env:POPULATE_DB='1' diff --git a/api/xls_persistence.py b/api/xls_persistence.py deleted file mode 100644 index e4b0c09e..00000000 --- a/api/xls_persistence.py +++ /dev/null @@ -1,47 +0,0 @@ -# =============================================================================== -# Copyright 2022 ross -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# =============================================================================== -import xlsxwriter -from geoalchemy2.elements import WKBElement - - -def populate_sheet(sh, records, columns): - for col, attr in enumerate(columns): - sh.write(0, col, attr.name) - # - for row, record in enumerate(records): - for col, attr in enumerate(columns): - try: - value = getattr(record, attr.name) - sh.write(row + 1, col, value) - except BaseException: - sh.write(row + 1, col, "") - - -def make_xls_backup(db, tables): - path = "backup.xlsx" - wb = xlsxwriter.Workbook(path) - - for table in tables: - records = db.query(table).all() - sh = wb.add_worksheet(table.__tablename__) - populate_sheet(sh, records, table.__table__.columns) - - wb.close() - - return path - - -# ============= EOF ============================================= From 1360fcc07b848aa88d85dbae7ff424ed96df882d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Feb 2026 11:18:02 -0600 Subject: [PATCH 24/91] feat(Settings): Add remove avatar btn to settings page --- .../src/components/ImageUploadWithPreview.tsx | 26 ++++++++--------- frontend/src/views/Settings.tsx | 29 ++++++++++++++++--- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/ImageUploadWithPreview.tsx b/frontend/src/components/ImageUploadWithPreview.tsx index 98abf5de..efefffe6 100644 --- a/frontend/src/components/ImageUploadWithPreview.tsx +++ b/frontend/src/components/ImageUploadWithPreview.tsx @@ -7,10 +7,7 @@ import { enqueueSnackbar } from "notistack"; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const VisuallyHiddenInput = (props: any) => ( - + ); export const ImageUploadWithPreview = ({ @@ -30,7 +27,7 @@ export const ImageUploadWithPreview = ({ if (!files) return; let imageFiles = Array.from(files).filter((file) => - file.type.startsWith("image/") + file.type.startsWith("image/"), ); // enforce max file size @@ -38,7 +35,7 @@ export const ImageUploadWithPreview = ({ if (tooBig.length > 0) { enqueueSnackbar( `Some files are too large. Max allowed size is ${MAX_FILE_SIZE / 1024 / 1024} MB.`, - { variant: "error" } + { variant: "error" }, ); imageFiles = imageFiles.filter((f) => f.size <= MAX_FILE_SIZE); } @@ -59,9 +56,12 @@ export const ImageUploadWithPreview = ({ } if (imageFiles.length > remaining) { - enqueueSnackbar(`Only ${remaining} more image${remaining > 1 ? "s" : ""} allowed.`, { - variant: "info", - }); + enqueueSnackbar( + `Only ${remaining} more image${remaining > 1 ? "s" : ""} allowed.`, + { + variant: "info", + }, + ); imageFiles = imageFiles.slice(0, remaining); } } @@ -104,7 +104,7 @@ export const ImageUploadWithPreview = ({ startIcon={} disabled={fileLimit !== undefined && files.length >= fileLimit} // disable when limit reached > - Upload photos + {`Upload photo${(fileLimit ?? 0) >= 2 ? "s" : ""}`} {fileLimit && ( - {files.length}/{fileLimit} images uploaded + {files.length}/{fileLimit} + {` image${(fileLimit ?? 0) >= 2 ? "s" : ""} uploaded`} )} @@ -137,5 +138,4 @@ export const ImageUploadWithPreview = ({ )} ); -} - +}; diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 6a4ba6d0..a6cdf344 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -25,7 +25,7 @@ import { } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; import { useAuthUser, useSignIn } from "react-auth-kit"; -import { Check, Close, Edit, ExpandMore } from "@mui/icons-material"; +import { Check, Close, Delete, Edit, ExpandMore } from "@mui/icons-material"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { BackgroundBox, @@ -369,9 +369,30 @@ export const Settings = () => { Avatar Configuration - - - + + + + + + + + From 5afa74edd0583d855c338484d3d82d5e2101e019 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Feb 2026 12:03:12 -0600 Subject: [PATCH 25/91] feat(waterlevels_report): Add Report Averages to PDF report --- api/routes/well_measurements.py | 197 +++++++++++++++----------- api/templates/waterlevels_report.html | 195 +++++++++++++++---------- 2 files changed, 239 insertions(+), 153 deletions(-) diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 37a7afcb..3a7704dc 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, Any, Dict from datetime import datetime, date import re @@ -359,94 +359,18 @@ def read_waterlevel_report_averages( >= 365 days => year buckets else => month buckets """ - DEPTH_TO_WATER_NAME = "Depth to water" - - if not well_ids: - return {"bucket": None, "per_well": [], "all_wells": []} if from_date is None and to_date is None: raise HTTPException( status_code=400, detail="from_date and/or to_date is required for reports" ) - # Build datetime bounds (inclusive end-of-day for to_date) - start_dt = datetime.combine(from_date, datetime.min.time()) if from_date else None - end_dt = datetime.combine(to_date, datetime.max.time()) if to_date else None - - # Decide bucket granularity based on provided range - # If one side missing, fall back to month (or choose a rule you prefer) - if from_date and to_date: - delta_days = (to_date - from_date).days - bucket_unit = "year" if delta_days >= 365 else "month" - else: - bucket_unit = "month" - - bucket = func.date_trunc(bucket_unit, WellMeasurements.timestamp).label( - "period_start" - ) - - base_filters = [ - ObservedPropertyTypeLU.name == DEPTH_TO_WATER_NAME, - WellMeasurements.well_id.in_(well_ids), - ] - if start_dt: - base_filters.append(WellMeasurements.timestamp >= start_dt) - if end_dt: - base_filters.append(WellMeasurements.timestamp <= end_dt) - - # 1) Per-well averages - per_well_stmt = ( - select( - WellMeasurements.well_id.label("well_id"), - Wells.ra_number.label("ra_number"), - bucket, - func.avg(WellMeasurements.value).label("avg_value"), - ) - .join(Wells, Wells.id == WellMeasurements.well_id) - .join( - ObservedPropertyTypeLU, - ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, - ) - .where(and_(*base_filters)) - .group_by(WellMeasurements.well_id, Wells.ra_number, bucket) - .order_by(Wells.ra_number, bucket) - ) - per_well_rows = db.execute(per_well_stmt).all() - - all_wells_stmt = ( - select( - bucket, - func.avg(WellMeasurements.value).label("avg_value"), - ) - .join( - ObservedPropertyTypeLU, - ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, - ) - .where(and_(*base_filters)) - .group_by(bucket) - .order_by(bucket) + return get_waterlevel_report_averages( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + db=db, ) - all_wells_rows = db.execute(all_wells_stmt).all() - - return { - "bucket": bucket_unit, # "month" or "year" - "per_well": [ - { - "well_id": r.well_id, - "ra_number": r.ra_number, - "period_start": r.period_start, - "avg_value": float(r.avg_value) if r.avg_value is not None else None, - } - for r in per_well_rows - ], - "all_wells": [ - { - "period_start": r.period_start, - "avg_value": float(r.avg_value) if r.avg_value is not None else None, - } - for r in all_wells_rows - ], - } @authenticated_well_measurement_router.get( @@ -573,6 +497,13 @@ def make_line_chart(data: dict, title: str): "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" ) + averages = get_waterlevel_report_averages( + well_ids=well_ids, + from_date=from_date, + to_date=to_date, + db=db, + ) + html = templates.get_template("waterlevels_report.html").render( from_date=from_date, to_date=to_date, @@ -580,6 +511,7 @@ def make_line_chart(data: dict, title: str): rows=rows, report_title=report_title, report_subtext=report_subtext, + averages=averages, ) pdf_io = BytesIO() @@ -634,3 +566,104 @@ def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): db.commit() return True + + +def get_waterlevel_report_averages( + *, + well_ids: List[int], + from_date: Optional[date], + to_date: Optional[date], + db: Session, +) -> Dict[str, Any]: + """ + Shared logic used by both JSON endpoint and PDF endpoint. + Returns: + { + "bucket": "month" | "year", + "per_well": [ { well_id, ra_number, period_start, avg_value }, ...], + "all_wells": [ { period_start, avg_value }, ...], + } + """ + DEPTH_TO_WATER_NAME = "Depth to water" + + if not well_ids: + return {"bucket": None, "per_well": [], "all_wells": []} + + if from_date is None and to_date is None: + # Let callers decide whether to raise; for PDF we always have both. + return {"bucket": None, "per_well": [], "all_wells": []} + + start_dt = datetime.combine(from_date, datetime.min.time()) if from_date else None + end_dt = datetime.combine(to_date, datetime.max.time()) if to_date else None + + if from_date and to_date: + delta_days = (to_date - from_date).days + bucket_unit = "year" if delta_days >= 365 else "month" + else: + bucket_unit = "month" + + bucket = func.date_trunc(bucket_unit, WellMeasurements.timestamp).label( + "period_start" + ) + + base_filters = [ + ObservedPropertyTypeLU.name == DEPTH_TO_WATER_NAME, + WellMeasurements.well_id.in_(well_ids), + ] + if start_dt: + base_filters.append(WellMeasurements.timestamp >= start_dt) + if end_dt: + base_filters.append(WellMeasurements.timestamp <= end_dt) + + per_well_stmt = ( + select( + WellMeasurements.well_id.label("well_id"), + Wells.ra_number.label("ra_number"), + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(WellMeasurements.well_id, Wells.ra_number, bucket) + .order_by(Wells.ra_number, bucket) + ) + per_well_rows = db.execute(per_well_stmt).all() + + all_wells_stmt = ( + select( + bucket, + func.avg(WellMeasurements.value).label("avg_value"), + ) + .join( + ObservedPropertyTypeLU, + ObservedPropertyTypeLU.id == WellMeasurements.observed_property_id, + ) + .where(and_(*base_filters)) + .group_by(bucket) + .order_by(bucket) + ) + all_wells_rows = db.execute(all_wells_stmt).all() + + return { + "bucket": bucket_unit, + "per_well": [ + { + "well_id": r.well_id, + "ra_number": r.ra_number, + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in per_well_rows + ], + "all_wells": [ + { + "period_start": r.period_start, + "avg_value": float(r.avg_value) if r.avg_value is not None else None, + } + for r in all_wells_rows + ], + } diff --git a/api/templates/waterlevels_report.html b/api/templates/waterlevels_report.html index 3a70d8b0..aef1a88b 100644 --- a/api/templates/waterlevels_report.html +++ b/api/templates/waterlevels_report.html @@ -1,85 +1,138 @@ + + + - .chart { - margin-top: 2em; - text-align: center; - } - - + +

{{ report_title }}

- -

{{ report_title }}

+ {% if report_subtext %} +

+ {{ report_subtext }} +

+ {% endif %} - {% if report_subtext %} -

- {{ report_subtext }} -

- {% endif %} +

+ From: {{ from_date }}    + To: {{ to_date }} +

-

- From: {{ from_date }}    - To: {{ to_date }} -

+ {% if observation_chart %} +
+

Depth of Water over Time

+ +
+ {% endif %} - {% if observation_chart %} -
-

Depth of Water over Time

- -
- {% endif %} +

Water Level Measurements

+ + + + + + + + + + {% for row in rows %} + + + + + + {% endfor %} + +
WellDate / TimeDepth to Water (ft)
{{ row.well_ra_number }}{{ row.timestamp }}{{ "%.2f"|format(row.depth_to_water) }}
-

Water Level Measurements

- - - - - - - - - - {% for row in rows %} - - - - - - {% endfor %} - -
Date / TimeDepth to Water (ft)Well
{{ row.timestamp }}{{ row.depth_to_water }}{{ row.well_ra_number }}
- + {% if averages and averages.bucket %} +

Report Averages ({{ averages.bucket | title }})

- \ No newline at end of file +

Per-well averages

+ + + + + + + + + + {% for row in averages.per_well %} + + + + + + {% endfor %} + +
WellPeriodAverage Depth to Water (ft)
{{ row.ra_number }}{{ row.period_start }} + {{ "%.2f"|format(row.avg_value) if row.avg_value is not none else "" + }} +
+ +

All-wells average

+ + + + + + + + + {% for row in averages.all_wells %} + + + + + {% endfor %} + +
PeriodAverage Depth to Water (ft)
{{ row.period_start }} + {{ "%.2f"|format(row.avg_value) if row.avg_value is not none else "" + }} +
+ {% endif %} + + From 6c619bc04663d2231489e4e9b3ea824a64040dd1 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 17 Feb 2026 23:26:59 -0600 Subject: [PATCH 26/91] feat(PartsHistory): init pg --- api/routes/meters.py | 58 +++--- api/routes/parts.py | 100 +++++++++- api/schemas/part_schemas.py | 23 ++- frontend/src/App.tsx | 14 ++ frontend/src/components/EventTypeChip.tsx | 50 +++++ frontend/src/components/TopbarUserButton.tsx | 44 ++++- frontend/src/components/index.ts | 1 + .../src/interfaces/PartHistoryResponse.ts | 17 ++ frontend/src/service/ApiServiceNew.ts | 20 ++ frontend/src/sidenav.tsx | 17 +- frontend/src/views/Parts/MeterTypesTable.tsx | 6 +- frontend/src/views/Parts/PartsHistory.tsx | 175 ++++++++++++++++++ frontend/src/views/Parts/PartsTable.tsx | 56 +++++- frontend/src/views/Parts/index.ts | 3 +- .../src/views/UserManagement/UsersTable.tsx | 6 +- .../src/views/WorkOrders/WorkOrdersTable.tsx | 49 ++++- 16 files changed, 584 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/EventTypeChip.tsx create mode 100644 frontend/src/interfaces/PartHistoryResponse.ts create mode 100644 frontend/src/views/Parts/PartsHistory.tsx diff --git a/api/routes/meters.py b/api/routes/meters.py index 9f78258d..889a2b00 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -12,6 +12,7 @@ Meters, LandOwners, MeterActivities, + PartsUsed, Parts, MeterObservations, Locations, @@ -36,6 +37,7 @@ PHOTO_JWT_EXPIRE_SECONDS = 600 # 10 minutes BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") + # Get paginated, sorted list of meters, filtered by a search string if applicable @authenticated_meter_router.get( "/meters", @@ -46,7 +48,7 @@ def get_meters( # offset: int, limit: int - From fastapi_pagination search_string: str = None, - filter_by_status: List[MeterStatus] = Query('Installed'), + filter_by_status: List[MeterStatus] = Query("Installed"), sort_by: MeterSortByField = MeterSortByField.SerialNumber, sort_direction: SortDirection = SortDirection.Ascending, db: Session = Depends(get_db), @@ -64,14 +66,17 @@ def sort_by_field_to_schema_field(name: MeterSortByField): case MeterSortByField.TRSS: return Locations.trss - + # If 'Warehouse' is in the filter, add 'On Hold' to the filter - if MeterStatus.OnHold not in filter_by_status and MeterStatus.Warehouse in filter_by_status: + if ( + MeterStatus.OnHold not in filter_by_status + and MeterStatus.Warehouse in filter_by_status + ): filter_by_status.append(MeterStatus.OnHold) # Convert enums to strings filter_by_status_str = [status.value for status in filter_by_status] - + # Build the query statement based on query params # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( @@ -99,9 +104,7 @@ def sort_by_field_to_schema_field(name: MeterSortByField): if sort_direction != SortDirection.Ascending: query_statement = query_statement.order_by(desc(schema_field_name)) else: - query_statement = query_statement.order_by( - schema_field_name - ) + query_statement = query_statement.order_by(schema_field_name) return paginate(db, query_statement) @@ -217,7 +220,7 @@ def get_meters_locations( if not meter_ids: return [] # Short-circuit if nothing matched - # Query latest PMs for those meters + # Query latest PMs for those meters pm_query = text( """ SELECT MAX(timestamp_start) AS last_pm, meter_id @@ -230,7 +233,7 @@ def get_meters_locations( pm_years = db.execute(pm_query, {"mids": meter_ids}).fetchall() pm_dict = {row.meter_id: row.last_pm for row in pm_years} - # Map to DTOs manually for added performance + # Map to DTOs manually for added performance meter_map_list = [] for row in result: meter_map_list.append( @@ -248,14 +251,13 @@ def get_meters_locations( "longitude": row.longitude, "trss": row.trss, }, - last_pm=pm_dict.get(row.id) + last_pm=pm_dict.get(row.id), ) ) return meter_map_list - def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = None): if not meter_id and not serial_number: raise HTTPException( @@ -264,6 +266,7 @@ def require_meter_id_or_serial_number(meter_id: int = None, serial_number: str = return meter_id, serial_number + # Get single, fully qualified meter # Can use either meter_id or serial_number @authenticated_meter_router.get( @@ -278,12 +281,12 @@ def get_meter( # Create the basic query query = select(Meters).options( - joinedload(Meters.meter_type), - joinedload(Meters.well).joinedload(Wells.location), - joinedload(Meters.status), - joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), - joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), - ) + joinedload(Meters.meter_type), + joinedload(Meters.well).joinedload(Wells.location), + joinedload(Meters.status), + joinedload(Meters.meter_register).joinedload(meterRegisters.dial_units), + joinedload(Meters.meter_register).joinedload(meterRegisters.totalizer_units), + ) # Filter by either meter by id or serial number if meter_id: @@ -314,13 +317,12 @@ def get_meter_types(db: Session = Depends(get_db)): def get_meter_registers(db: Session = Depends(get_db)): query = select(meterRegisters).options( joinedload(meterRegisters.dial_units), - joinedload(meterRegisters.totalizer_units) + joinedload(meterRegisters.totalizer_units), ) return db.scalars(query).all() - # A route to return status types from the MeterStatusLU table @authenticated_meter_router.get( "/meter_status_types", @@ -468,7 +470,9 @@ class HistoryType(Enum): joinedload(MeterActivities.location), joinedload(MeterActivities.submitting_user), joinedload(MeterActivities.activity_type), - joinedload(MeterActivities.parts_used).joinedload(Parts.part_type), + joinedload(MeterActivities.parts_used_links) + .joinedload(PartsUsed.part) + .joinedload(Parts.part_type), joinedload(MeterActivities.notes), joinedload(MeterActivities.services_performed), ) @@ -496,8 +500,10 @@ class HistoryType(Enum): for activity in activities: activity.location.geom = None # FastAPI errors when returning this - #Find if there is a well associated with the location - activity_well = db.scalars(select(Wells).where(Wells.location_id == activity.location_id)).first() + # Find if there is a well associated with the location + activity_well = db.scalars( + select(Wells).where(Wells.location_id == activity.location_id) + ).first() photos = [ { @@ -526,8 +532,10 @@ class HistoryType(Enum): for observation in observations: observation.location.geom = None - #Find if there is a well associated with the location - observation_well = db.scalars(select(Wells).where(Wells.location_id == observation.location_id)).first() + # Find if there is a well associated with the location + observation_well = db.scalars( + select(Wells).where(Wells.location_id == observation.location_id) + ).first() formattedHistoryItems.append( { @@ -556,7 +564,7 @@ def create_signed_url(blob_path: str) -> str: creds = impersonated_credentials.Credentials( source_credentials=source_creds, target_principal=target_sa, - target_scopes=['https://www.googleapis.com/auth/devstorage.read_only'], + target_scopes=["https://www.googleapis.com/auth/devstorage.read_only"], lifetime=3600, ) diff --git a/api/routes/parts.py b/api/routes/parts.py index 62f7f16b..863b0847 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,8 +1,8 @@ from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload, selectinload -from sqlalchemy import select, func +from sqlalchemy import select, func, literal, union_all from typing import List, Union, Optional -from datetime import datetime, date +from datetime import datetime, date, time from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO @@ -447,3 +447,99 @@ def add_parts(payload: part_schemas.PartsAddRequest, db: Session = Depends(get_d part_obj, curr = row part_obj.current_count = curr return part_obj + + +@part_router.get( + "/parts/{part_id}/history", + response_model=part_schemas.PartHistoryResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def get_part_history(part_id: int, db: Session = Depends(get_db)): + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + # ADDED events (date is a DATE) + added_q = select( + PartsAdded.id.label("ref_id"), + PartsAdded.part_id.label("part_id"), + PartsAdded.date.label("event_date"), # date + literal("added").label("event_type"), + PartsAdded.note.label("note"), + PartsAdded.count.label("delta"), + literal(None).label("work_order_id"), + ).where(PartsAdded.part_id == part_id) + + # USED events (datetime comes from MeterActivities.timestamp_start) + used_q = ( + select( + PartsUsed.id.label("ref_id"), + PartsUsed.part_id.label("part_id"), + MeterActivities.timestamp_start.label("event_date"), # datetime + literal("used").label("event_type"), + MeterActivities.description.label("note"), + (-PartsUsed.count).label("delta"), + MeterActivities.work_order_id.label("work_order_id"), + ) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .where(PartsUsed.part_id == part_id) + ) + + events = union_all(added_q, used_q).subquery() + + rows = db.execute( + select( + events.c.ref_id, + events.c.part_id, + events.c.event_date, + events.c.event_type, + events.c.note, + events.c.delta, + events.c.work_order_id, + ).order_by(events.c.event_date.asc(), events.c.ref_id.asc()) + ).all() + + running = int(part.initial_count) + + history: list[part_schemas.PartHistoryRow] = [ + part_schemas.PartHistoryRow( + row_id=f"initial-{part_id}", + part_id=part_id, + event_date=datetime.min, + event_type="initial", + ref_id=None, + note="Initial count", + delta=0, + total_after=running, + work_order_id=None, + ) + ] + + for ref_id, pid, event_date, event_type, note, delta, work_order_id in rows: + # convert DATE -> DATETIME if needed + if not isinstance(event_date, datetime): + event_date = datetime.combine(event_date, time.min) + + running += int(delta) + + history.append( + part_schemas.PartHistoryRow( + row_id=f"{event_type}-{ref_id}", + part_id=pid, + event_date=event_date, + event_type=event_type, + ref_id=ref_id, + note=note, + delta=int(delta), + total_after=running, + work_order_id=work_order_id, + ) + ) + + return part_schemas.PartHistoryResponse( + part_id=part.id, + part_number=part.part_number, + initial_count=part.initial_count, + history=history, + ) diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 776cbf94..7d04d3c4 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -1,5 +1,5 @@ -from typing import Optional -from datetime import date +from typing import List, Literal, Optional +from datetime import date, datetime from api.schemas.base import ORMBase from api.schemas.meter_schemas import MeterTypeLU @@ -56,3 +56,22 @@ class PartsAddRequest(ORMBase): count: int date: date note: Optional[str] = None + + +class PartHistoryRow(ORMBase): + row_id: str + part_id: int + event_date: datetime + event_type: Literal["initial", "added", "used"] + ref_id: int | None = None + work_order_id: int | None = None + note: str | None = None + delta: int + total_after: int + + +class PartHistoryResponse(ORMBase): + part_id: int + part_number: str + initial_count: int + history: List[PartHistoryRow] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 024d349e..54f4a1c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { WorkOrdersView, ChloridesView, ReportsView, + PartsHistory, } from "./views"; import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; import { MaintenanceReportView } from "./views/Reports/Maintenance"; @@ -244,6 +245,19 @@ export const App = () => { } /> + + + + + + } + /> { + switch (event_type) { + case "added": { + return ( + + ); + } + case "used": { + return ( + + ); + } + case "initial": { + return ( + + ); + } + default: { + return ( + + ); + } + } +}; diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index d0bf7eaa..d2bf2bd0 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -1,6 +1,12 @@ -import { Avatar, Button, ButtonProps } from "@mui/material"; +import { + Avatar, + Button, + ButtonProps, + useTheme, + useMediaQuery, + IconButton, +} from "@mui/material"; import { Badge, Engineering, Face } from "@mui/icons-material"; -import { useTheme } from "@mui/material/styles"; import { getRoleColor } from "@/utils"; export const TopbarUserButton = ({ @@ -14,6 +20,7 @@ export const TopbarUserButton = ({ src?: string; } & ButtonProps) => { const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); const buttonColor = getRoleColor(role); const primary = theme.palette.primary; @@ -42,7 +49,36 @@ export const TopbarUserButton = ({ OSE: warning.contrastText, }; - return ( + return isSmallScreen ? ( + + + {src ? null : renderRoleIcon()} + + + ) : ( ), diff --git a/frontend/src/views/Parts/index.ts b/frontend/src/views/Parts/index.ts index e55dff06..57f2e9fe 100644 --- a/frontend/src/views/Parts/index.ts +++ b/frontend/src/views/Parts/index.ts @@ -1 +1,2 @@ -export * from './PartsView' +export * from "./PartsView"; +export * from "./PartsHistory"; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 886390ea..cb479973 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -79,7 +79,8 @@ export const UsersTable = ({ { + // supports: + // ?work_order_id=617 + // ?work_order_id=617,618 + // ?work_order_id=617&work_order_id=618 + const all = searchParams.getAll("work_order_id"); + if (!all || all.length === 0) return null; + + const ids = all + .flatMap((v) => v.split(",")) + .map((v) => v.trim()) + .filter(Boolean) + .map((v) => Number(v)) + .filter((n) => Number.isFinite(n) && n > 0); + + return ids.length ? ids : null; + }, [searchParams]); + const [workOrderFilters, setWorkOrderFilters] = useState([ WorkOrderStatus.Open, WorkOrderStatus.Review, @@ -43,6 +62,13 @@ export default function WorkOrdersTable() { const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = useState(false); + const displayedRows = useMemo(() => { + const rows = workOrderList.data ?? []; + if (!workOrderIdFilter) return rows; + const set = new Set(workOrderIdFilter); + return rows.filter((r: any) => set.has(r.work_order_id)); + }, [workOrderList.data, workOrderIdFilter]); + //Current user needed for various changes to UI based on user role const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -137,6 +163,7 @@ export default function WorkOrdersTable() { { field: "work_order_id", headerName: "ID", + type: "number", flex: 1, minWidth: 50, }, @@ -286,7 +313,7 @@ export default function WorkOrdersTable() { } aria-label="Edit Activity" > - + )} "auto"} getRowId={(row) => row.work_order_id} columns={columns} disableColumnResize={false} + filterModel={workOrderIdFilter?.length ? { items: [] } : undefined} initialState={{ columns: { columnVisibilityModel: { @@ -320,7 +353,9 @@ export default function WorkOrdersTable() { assigned_user_id: hasAdminScope, }, }, - filter: { filterModel: { items: initialFilter } }, + ...(workOrderIdFilter?.length + ? {} // NO default filter when URL param exists + : { filter: { filterModel: { items: initialFilter } } }), }} processRowUpdate={handleRowUpdate} onProcessRowUpdateError={handleProcessRowUpdateError} From 5c1f976a739078dcdfe890f9be131a152d85083b Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 18 Feb 2026 11:22:56 -0600 Subject: [PATCH 27/91] feat(PartsHistory): Add from, to, type, and note search filtering --- api/routes/parts.py | 15 +- api/schemas/part_schemas.py | 1 + frontend/src/components/EventTypeChip.tsx | 12 +- .../src/interfaces/PartHistoryResponse.ts | 1 + frontend/src/views/Parts/PartsHistory.tsx | 181 ++++++++++++++++-- 5 files changed, 189 insertions(+), 21 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 863b0847..20d7b186 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -16,6 +16,7 @@ MeterTypeLU, meterRegisters, MeterActivities, + workOrders, ) from api.schemas import part_schemas from api.session import get_db @@ -478,11 +479,20 @@ def get_part_history(part_id: int, db: Session = Depends(get_db)): PartsUsed.part_id.label("part_id"), MeterActivities.timestamp_start.label("event_date"), # datetime literal("used").label("event_type"), - MeterActivities.description.label("note"), + func.coalesce( + func.nullif(func.trim(MeterActivities.description), ""), + func.nullif(func.trim(workOrders.description), ""), + func.nullif(func.trim(workOrders.notes), ""), + func.nullif(func.trim(workOrders.title), ""), + ).label("note"), (-PartsUsed.count).label("delta"), MeterActivities.work_order_id.label("work_order_id"), ) .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .outerjoin( + workOrders, + workOrders.id == MeterActivities.work_order_id, + ) .where(PartsUsed.part_id == part_id) ) @@ -537,9 +547,12 @@ def get_part_history(part_id: int, db: Session = Depends(get_db)): ) ) + current_count = running + return part_schemas.PartHistoryResponse( part_id=part.id, part_number=part.part_number, initial_count=part.initial_count, + current_count=current_count, history=history, ) diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 7d04d3c4..012f4c88 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -74,4 +74,5 @@ class PartHistoryResponse(ORMBase): part_id: int part_number: str initial_count: int + current_count: int history: List[PartHistoryRow] diff --git a/frontend/src/components/EventTypeChip.tsx b/frontend/src/components/EventTypeChip.tsx index 260d23c0..e9d63d8d 100644 --- a/frontend/src/components/EventTypeChip.tsx +++ b/frontend/src/components/EventTypeChip.tsx @@ -3,7 +3,7 @@ import { Chip } from "@mui/material"; export const EventTypeChip = ({ event_type, }: { - event_type: "added" | "used" | "initial" | string; + event_type: "added" | "used" | "initial" | "current" | string; }) => { switch (event_type) { case "added": { @@ -36,6 +36,16 @@ export const EventTypeChip = ({ /> ); } + case "current": { + return ( + + ); + } default: { return ( ().nullable(), + to: yup + .mixed() + .nullable() + .required("To date is required") + .test("is-after", "'To' date must be after 'From'", function (value) { + const { from } = this.parent; + return !from || !value || dayjs(value).isAfter(dayjs(from)); + }), + event_types: yup + .array() + .of(yup.string().oneOf(["initial", "used", "added", "current"]).required()) + .min(1, "Select at least one event type") + .required(), +}); + +const defaultSchema = { + from: null, + to: dayjs().endOf("month"), + event_types: ["initial", "used", "added", "current"] as ( + | "initial" + | "used" + | "added" + | "current" + )[], +}; export const PartsHistory = () => { const { id } = useParams<{ id: string }>(); const history = useGetPartHistory(id); const [search, setSearch] = useState(""); + const { control, watch, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + + const from = watch("from"); + const to = watch("to"); + const eventTypes = watch("event_types"); + const rows = useMemo(() => { const raw = history.data?.history ?? []; const q = search.trim().toLowerCase(); - if (!q) return raw; - return raw.filter((r) => { - return ( - r.event_type.toLowerCase().includes(q) || - (r.note ?? "").toLowerCase().includes(q) - ); + const fromDate = from ? dayjs(from).startOf("day") : null; + const toDate = to ? dayjs(to).endOf("day") : null; + + const selectedTypes = new Set((eventTypes ?? []) as string[]); + + const currentRow = + history.data?.current_count != null + ? { + row_id: `current-${id ?? "unknown"}`, + part_id: Number(id), + event_date: dayjs().toISOString(), // "today" + event_type: "current" as const, + ref_id: null, + note: "Current count", + delta: 0, + total_after: history.data.current_count, + work_order_id: null, + } + : null; + + const withCurrent = currentRow ? [...raw, currentRow] : raw; + + return withCurrent.filter((r: any) => { + // event type filter + if (selectedTypes.size && !selectedTypes.has(r.event_type)) return false; + + // note search filter + if (q && !(r.note ?? "").toLowerCase().includes(q)) return false; + + // date range filter + if (r.event_type === "initial") return selectedTypes.has("initial"); // keep initial independent of date + if (r.event_type === "current") return selectedTypes.has("current"); + + const d = dayjs(r.event_date); + if (fromDate && d.isBefore(fromDate)) return false; + if (toDate && d.isAfter(toDate)) return false; + + return true; }); - }, [history.data, search]); + }, [history.data, search, from, to, eventTypes]); const cols: GridColDef[] = [ { @@ -41,8 +129,11 @@ export const PartsHistory = () => { width: 200, renderCell: (params) => { const row = params.row; - if (row.event_type === "initial") return "Initial"; - const d = new Date(params.value); + if (row.event_type === "initial") return "-"; + + const d = + row.event_type === "current" ? new Date() : new Date(params.value); + return isNaN(d.getTime()) ? String(params.value) : dayjs(d).format("MMM D, YYYY h:mm A"); @@ -128,8 +219,8 @@ export const PartsHistory = () => { - - + + @@ -138,20 +229,69 @@ export const PartsHistory = () => { - + + + + + + + + + opt === "used" + ? "Work Orders" + : opt === "added" + ? "Parts Added" + : opt === "current" + ? "Current" + : "Initial" + } + /> + + setSearch(e.target.value)} sx={{ width: { xs: "100%", md: 360 } }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - - row.row_id} loading={history.isLoading} @@ -167,6 +307,9 @@ export const PartsHistory = () => { }} /> + + + From 1c17fcb808ed5af91545091a95bdb76d2718ccc2 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 26 Feb 2026 14:18:25 -0600 Subject: [PATCH 28/91] feat(export_meter_readings): Add script to export meter readings --- scripts/export_meter_readings.sql | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 scripts/export_meter_readings.sql diff --git a/scripts/export_meter_readings.sql b/scripts/export_meter_readings.sql new file mode 100644 index 00000000..d3e7a7ee --- /dev/null +++ b/scripts/export_meter_readings.sql @@ -0,0 +1,54 @@ +/* + Meter Readings Export (Hydrologist-Friendly CSV) + + This query exports all "Meter reading" observations from the + MeterObservations table in a format suitable for non-technical + users (hydrologists, consultants, regulators). + + - Reading Date is formatted as YYYY-MM-DD (date only) + - Reading Value and Reading Unit are in separate columns + - Well and Location identifiers are human-readable (no DB IDs) + - Includes Meter ID for traceability (optional, but helpful) + - Join to Wells is performed via shared Location (w.location_id = mo.location_id) +*/ +SELECT + "Well Name", + "RA Number", + "Meter Reading Date", + "Meter Reading Value", + "Meter Reading Unit", + "Parameter", + "Location Name", + "Latitude", + "Longitude", + "Location Geometry (WKT)", + "Meter ID" +FROM ( + SELECT + l.name AS "Location Name", + w.name AS "Well Name", + w.ra_number AS "RA Number", + to_char(mo."timestamp"::date, 'YYYY-MM-DD') AS "Meter Reading Date", + opt.name AS "Parameter", + mo.value AS "Meter Reading Value", + u.name_short AS "Meter Reading Unit", + l.latitude AS "Latitude", + l.longitude AS "Longitude", + ST_AsText(l.geom) AS "Location Geometry (WKT)", + mo.meter_id AS "Meter ID" + FROM public."MeterObservations" mo + JOIN public."Units" u + ON u.id = mo.unit_id + JOIN public."ObservedPropertyTypeLU" opt + ON opt.id = mo.observed_property_type_id + JOIN public."Locations" l + ON l.id = mo.location_id + JOIN public."Wells" w + ON w.location_id = mo.location_id + WHERE mo.observed_property_type_id = 1 -- Meter reading +) t +ORDER BY + t."Meter Reading Date" ASC, + t."Well Name" ASC, + t."Location Name" ASC; + From b38559d5afc04114fa2652927c8fc91995334158 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 02:08:33 -0600 Subject: [PATCH 29/91] refactor(frontend)!: BREAKING CHANGE swap react router dom for tanstack router --- frontend/package-lock.json | 1517 +++++++++++++++-- frontend/package.json | 4 +- frontend/src/App.tsx | 282 +-- frontend/src/ProtectedRoute.tsx | 14 +- frontend/src/components/LinkBehavior.tsx | 10 + frontend/src/components/NavLink.tsx | 4 +- frontend/src/components/ReportsNavItem.tsx | 4 +- frontend/src/components/Topbar.tsx | 10 +- .../src/components/UI/TanstackIconButton.tsx | 9 + frontend/src/components/UI/index.ts | 1 + frontend/src/components/index.ts | 2 + frontend/src/contexts/ErrorMessageContext.tsx | 46 + frontend/src/hooks/useFetchWithAuth.ts | 4 +- frontend/src/hooks/useIsActiveRoute.ts | 7 +- frontend/src/routeTree.gen.ts | 482 ++++++ frontend/src/router.tsx | 12 + frontend/src/routes/__root.tsx | 12 + frontend/src/routes/activities.tsx | 29 + .../$activity_id/photos/$photo_file_name.tsx | 13 + frontend/src/routes/chlorides.tsx | 6 + frontend/src/routes/index.tsx | 6 + frontend/src/routes/login.tsx | 6 + frontend/src/routes/manage/backups.tsx | 11 + frontend/src/routes/manage/meters.tsx | 46 + frontend/src/routes/manage/parts.tsx | 10 + .../src/routes/manage/parts/$id/history.tsx | 11 + frontend/src/routes/manage/parts/index.tsx | 6 + frontend/src/routes/manage/users.tsx | 11 + frontend/src/routes/manage/wells.tsx | 11 + frontend/src/routes/monitoringwells.tsx | 6 + frontend/src/routes/reports/chlorides.tsx | 11 + frontend/src/routes/reports/index.tsx | 11 + frontend/src/routes/reports/maintenance.tsx | 11 + .../src/routes/reports/monitoringwells.tsx | 11 + frontend/src/routes/reports/partsused.tsx | 11 + frontend/src/routes/settings.tsx | 11 + frontend/src/routes/workorders.tsx | 26 + frontend/src/service/ApiServiceNew.ts | 4 +- frontend/src/sidenav.tsx | 4 +- .../views/Activities/ActivityPhotoView.tsx | 6 +- .../MeterActivityEntry/MeterActivityEntry.tsx | 32 +- frontend/src/views/Login.tsx | 6 +- .../src/views/Meters/MeterDetailsFields.tsx | 13 +- .../Meters/MeterHistory/MeterHistory.tsx | 62 +- .../Meters/MeterSelection/MeterSelection.tsx | 46 +- frontend/src/views/Meters/MetersView.tsx | 147 +- frontend/src/views/NotFound.tsx | 2 +- frontend/src/views/Parts/PartsHistory.tsx | 233 ++- frontend/src/views/Parts/PartsTable.tsx | 21 +- .../src/views/Reports/Chlorides/index.tsx | 2 +- .../src/views/Reports/Maintenance/index.tsx | 2 +- .../views/Reports/MonitoringWells/index.tsx | 2 +- .../src/views/Reports/PartsUsed/index.tsx | 2 +- .../WellManagement/WellSelectionTable.tsx | 15 +- .../src/views/WorkOrders/WorkOrdersTable.tsx | 62 +- frontend/vite.config.ts | 9 +- 56 files changed, 2747 insertions(+), 607 deletions(-) create mode 100644 frontend/src/components/LinkBehavior.tsx create mode 100644 frontend/src/components/UI/TanstackIconButton.tsx create mode 100644 frontend/src/components/UI/index.ts create mode 100644 frontend/src/contexts/ErrorMessageContext.tsx create mode 100644 frontend/src/routeTree.gen.ts create mode 100644 frontend/src/router.tsx create mode 100644 frontend/src/routes/__root.tsx create mode 100644 frontend/src/routes/activities.tsx create mode 100644 frontend/src/routes/activities/$activity_id/photos/$photo_file_name.tsx create mode 100644 frontend/src/routes/chlorides.tsx create mode 100644 frontend/src/routes/index.tsx create mode 100644 frontend/src/routes/login.tsx create mode 100644 frontend/src/routes/manage/backups.tsx create mode 100644 frontend/src/routes/manage/meters.tsx create mode 100644 frontend/src/routes/manage/parts.tsx create mode 100644 frontend/src/routes/manage/parts/$id/history.tsx create mode 100644 frontend/src/routes/manage/parts/index.tsx create mode 100644 frontend/src/routes/manage/users.tsx create mode 100644 frontend/src/routes/manage/wells.tsx create mode 100644 frontend/src/routes/monitoringwells.tsx create mode 100644 frontend/src/routes/reports/chlorides.tsx create mode 100644 frontend/src/routes/reports/index.tsx create mode 100644 frontend/src/routes/reports/maintenance.tsx create mode 100644 frontend/src/routes/reports/monitoringwells.tsx create mode 100644 frontend/src/routes/reports/partsused.tsx create mode 100644 frontend/src/routes/settings.tsx create mode 100644 frontend/src/routes/workorders.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3f7d7aea..a6e3f70e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", + "@tanstack/react-router": "^1.99.7", "dayjs": "^1.11.9", "immer": "^10.0.2", "js-yaml": "^4.1.1", @@ -34,14 +35,13 @@ "react-number-format": "^5.3.1", "react-plotly.js": "^2.6.0", "react-query": "^3.39.3", - "react-router": "^6.30.3", - "react-router-dom": "^6.30.3", "serve": "^14.0.1", "use-debounce": "^9.0.4", "yup": "^1.2.0" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@tanstack/router-plugin": "^1.99.7", "@types/geojson": "^7946.0.14", "@types/jest": "^29.5.2", "@types/leaflet": "^1.9.16", @@ -62,73 +62,219 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.9.tgz", - "integrity": "sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -137,6 +283,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", @@ -147,54 +325,45 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.9.tgz", - "integrity": "sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.9", - "@babel/parser": "^7.26.9", - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1148,6 +1317,23 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", @@ -1525,17 +1711,24 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1547,15 +1740,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", @@ -1574,9 +1758,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2963,6 +3147,220 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/history": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.4.tgz", + "integrity": "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.163.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.163.3.tgz", + "integrity": "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/react-store": "^0.9.1", + "@tanstack/router-core": "1.163.3", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", + "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.163.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.163.3.tgz", + "integrity": "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.4", + "@tanstack/store": "^0.9.1", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.163.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.163.3.tgz", + "integrity": "sha512-i2rWRtqY/yCYUDXva1li4zeDP20oFjMt/wh9RnGJCrKSLWrvEGnxAOSyXgiOsoJnU96TTQ0mUDbGfXsSTupeZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-core": "1.163.3", + "@tanstack/router-utils": "1.161.4", + "@tanstack/virtual-file-routes": "1.161.4", + "prettier": "^3.5.0", + "recast": "^0.23.11", + "source-map": "^0.7.4", + "tsx": "^4.19.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.163.3", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.163.3.tgz", + "integrity": "sha512-JOUYuUX2N9ZHnmkmvmiGzXGbkvrur/5BfW/+vpiZzuifSyvdc0XsfwkTpjvwWx9ymp4ZshSVKiQQKQi09YweIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.163.3", + "@tanstack/router-generator": "1.163.3", + "@tanstack/router-utils": "1.161.4", + "@tanstack/virtual-file-routes": "1.161.4", + "chokidar": "^3.6.0", + "unplugin": "^2.1.2", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2", + "@tanstack/react-router": "^1.163.3", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", + "vite-plugin-solid": "^2.11.10", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.4.tgz", + "integrity": "sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", + "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.161.4", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.161.4.tgz", + "integrity": "sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@turf/area": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", @@ -3754,9 +4152,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3917,18 +4315,42 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" }, { "type": "consulting", @@ -3985,6 +4407,32 @@ "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==", "license": "MIT" }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4030,6 +4478,19 @@ "node": ">=0.6" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/binary-search-bounds": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", @@ -4150,7 +4611,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -4218,8 +4678,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0", - "peer": true + "license": "CC-BY-4.0" }, "node_modules/canvas-fit": { "version": "1.5.0", @@ -4261,6 +4720,44 @@ "url": "https://github.com/chalk/chalk-template?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -4519,6 +5016,12 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4949,6 +5452,16 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -5022,8 +5535,7 @@ "version": "1.5.113", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/element-size": { "version": "1.1.1", @@ -5183,7 +5695,6 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5750,6 +6261,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/geojson-vt": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", @@ -5774,6 +6295,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/gl-mat4": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", @@ -6347,6 +6881,19 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-browser": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", @@ -6533,6 +7080,15 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/isbot": { + "version": "5.1.35", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.35.tgz", + "integrity": "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6729,6 +7285,19 @@ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jwt-decode": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", @@ -6839,6 +7408,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/map-limit": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", @@ -7244,8 +7823,17 @@ "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=0.10.0" + } }, "node_modules/normalize-svg-path": { "version": "0.1.0", @@ -7518,6 +8106,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -7756,6 +8351,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -8142,6 +8753,46 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", @@ -8294,6 +8945,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/resolve-protobuf-schema": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", @@ -8534,6 +9195,27 @@ "randombytes": "^2.1.0" } }, + "node_modules/seroval": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", + "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", + "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/serve": { "version": "14.2.4", "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz", @@ -9058,6 +9740,18 @@ "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -9185,54 +9879,541 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "license": "ISC" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1" + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" + "node": ">=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/typedarray-pool": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", - "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "bit-twiddle": "^1.0.0", - "dup": "^1.0.0" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/typescript": { + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, + "node_modules/typescript": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", @@ -9285,6 +10466,35 @@ "detect-node": "^2.0.4" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", @@ -9310,7 +10520,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -9360,9 +10569,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -9586,6 +10795,13 @@ "node": ">=10.13.0" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -9703,6 +10919,13 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yaml": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", @@ -9742,6 +10965,16 @@ "toposort": "^2.0.2", "type-fest": "^2.19.0" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 07e2ae50..e9bfeb0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", + "@tanstack/react-router": "^1.99.7", "dayjs": "^1.11.9", "immer": "^10.0.2", "js-yaml": "^4.1.1", @@ -36,14 +37,13 @@ "react-number-format": "^5.3.1", "react-plotly.js": "^2.6.0", "react-query": "^3.39.3", - "react-router": "^6.30.3", - "react-router-dom": "^6.30.3", "serve": "^14.0.1", "use-debounce": "^9.0.4", "yup": "^1.2.0" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@tanstack/router-plugin": "^1.99.7", "@types/geojson": "^7946.0.14", "@types/jest": "^29.5.2", "@types/leaflet": "^1.9.16", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54f4a1c9..de4e65ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,47 +1,15 @@ -import { useEffect, useState } from "react"; import { AuthProvider } from "react-auth-kit"; -import { Route, BrowserRouter as Router, Routes } from "react-router-dom"; +import { RouterProvider } from "@tanstack/react-router"; import { QueryClient, QueryClientProvider } from "react-query"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { LocalizationProvider } from "@mui/x-date-pickers"; -import { SnackbarProvider, enqueueSnackbar } from "notistack"; -import { - ActivitiesView, - MonitoringWellsView, - BackupsView, - Home, - Login, - Settings, - NotFound, - ActivityPhotoView, - MetersView, - PartsView, - UserManagementView, - WellManagementView, - WorkOrdersView, - ChloridesView, - ReportsView, - PartsHistory, -} from "./views"; -import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; -import { MaintenanceReportView } from "./views/Reports/Maintenance"; -import { PartsUsedReportView } from "./views/Reports/PartsUsed"; -import { ChloridesReportView } from "./views/Reports/Chlorides"; - -import { AppLayout } from "./AppLayout"; -import { ProtectedRoute } from "./ProtectedRoute"; +import { SnackbarProvider } from "notistack"; +import { router } from "./router"; +import { ErrorMessageProvider } from "./contexts/ErrorMessageContext"; export const App = () => { const queryClient = new QueryClient(); - // Showing messages between navigation (eg: accessing forbidden page, accessing while not logged in) results in duplicated snackbars, this is a workaround - const [errorMessage, setErrorMessage] = useState(); - useEffect(() => { - if (errorMessage) { - enqueueSnackbar(errorMessage, { variant: "error" }); - } - }, [errorMessage]); - return ( @@ -55,245 +23,9 @@ export const App = () => { cookieDomain={window.location.hostname} cookieSecure={window.location.protocol === "https:"} > - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - } - /> - - + + + diff --git a/frontend/src/ProtectedRoute.tsx b/frontend/src/ProtectedRoute.tsx index d607e2e6..cced2860 100644 --- a/frontend/src/ProtectedRoute.tsx +++ b/frontend/src/ProtectedRoute.tsx @@ -1,22 +1,22 @@ -import { Navigate } from "react-router-dom"; +import { Navigate } from "@tanstack/react-router"; import { useAuthUser, useIsAuthenticated } from "react-auth-kit"; import { SecurityScope } from "./interfaces"; +import { useErrorMessage } from "./contexts/ErrorMessageContext"; export const ProtectedRoute = ({ children, requiredScopes, - setErrorMessage, }: { children: JSX.Element; requiredScopes?: string[]; - setErrorMessage?: (msg: string) => void; }) => { const isAuthenticated = useIsAuthenticated(); const authUser = useAuthUser(); + const { setErrorMessage } = useErrorMessage(); // Case 1: Not logged in if (!isAuthenticated()) { - if (setErrorMessage) setErrorMessage("You must login to view this page."); + setErrorMessage("You must login to view this page."); return ; } @@ -27,15 +27,13 @@ export const ProtectedRoute = ({ ) ?? []; if (userScopes.length === 0) { - if (setErrorMessage) - setErrorMessage("Your account does not have any permissions."); + setErrorMessage("Your account does not have any permissions."); return ; } // Case 3: Missing required scopes if (requiredScopes && !requiredScopes.every((s) => userScopes.includes(s))) { - if (setErrorMessage) - setErrorMessage("You do not have sufficient permissions."); + setErrorMessage("You do not have sufficient permissions."); return ; } diff --git a/frontend/src/components/LinkBehavior.tsx b/frontend/src/components/LinkBehavior.tsx new file mode 100644 index 00000000..89e50baf --- /dev/null +++ b/frontend/src/components/LinkBehavior.tsx @@ -0,0 +1,10 @@ +import { Link as RouterLink } from "@tanstack/react-router"; +import { forwardRef } from "react"; + +// MUI expects the component to forwardRef to an element +export const LinkBehavior = forwardRef< + HTMLAnchorElement, + React.ComponentProps +>(function LinkBehavior(props, ref) { + return ; +}); diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index 40d14ab8..325b4ec3 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -7,7 +7,7 @@ import { ListItemText, } from "@mui/material"; import { TableView } from "@mui/icons-material"; -import { Link, type LinkProps } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { useIsActiveRoute } from "@/hooks"; export const NavLink = ({ @@ -19,7 +19,7 @@ export const NavLink = ({ subItem = false, }: { disabled?: boolean; - route: LinkProps["to"]; + route: string; label: string; icon?: React.ComponentType; badgeContent?: number; diff --git a/frontend/src/components/ReportsNavItem.tsx b/frontend/src/components/ReportsNavItem.tsx index 28ac2a88..893470ba 100644 --- a/frontend/src/components/ReportsNavItem.tsx +++ b/frontend/src/components/ReportsNavItem.tsx @@ -6,7 +6,7 @@ import { ListItemText, } from "@mui/material"; import { Assessment, ExpandLess, ExpandMore } from "@mui/icons-material"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "@tanstack/react-router"; import { useIsActiveRoute } from "@/hooks"; export function ReportsNavItem({ @@ -39,7 +39,7 @@ export function ReportsNavItem({ } e.stopPropagation(); setOpen(false); - navigate("/reports"); + navigate({ to: "/reports" }); }; return ( diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 4d19b01d..9667e7ef 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -13,7 +13,7 @@ import { ListItemIcon, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "@tanstack/react-router"; import { useAuthUser, useSignOut } from "react-auth-kit"; import { Login, Logout, Settings } from "@mui/icons-material"; import { RoleChip, TopbarUserButton } from "./index"; @@ -45,7 +45,7 @@ export const Topbar = ({ }; const fullSignOut = () => { - navigate("/"); + navigate({ to: "/" }); localStorage.removeItem("loggedIn"); signOut(); }; @@ -84,7 +84,7 @@ export const Topbar = ({ xl: "2rem", }, }} - onClick={() => navigate("/")} + onClick={() => navigate({ to: "/" })} > Meter Manager @@ -128,7 +128,7 @@ export const Topbar = ({ { - navigate("/settings"); + navigate({ to: "/settings" }); handleMenuClose(); }} > @@ -153,7 +153,7 @@ export const Topbar = ({ ) : ( + + + + )} + + + + ); + }, + }} /> - + + {hasChanges && } + setSnackbar(null)} + > + setSnackbar(null)}> + {snackbar?.message} + + ); }; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 9a454d49..c3fe7a60 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -6,7 +6,6 @@ import { Card, CardContent, Grid, - IconButton, InputAdornment, Stack, TextField, @@ -20,7 +19,7 @@ import { History, } from "@mui/icons-material"; import { useSnackbar } from "notistack"; -import { Link } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { useGetParts, useAddParts } from "@/service"; import { Part } from "@/interfaces"; import { @@ -28,6 +27,7 @@ import { GridFooterWithButton, IncreaseQuantityModal, IsTrueChip, + TanstackIconButton, TristateToggle, } from "@/components"; @@ -72,21 +72,16 @@ export const PartsTable = ({ }} > {params.value} - e.stopPropagation()} - sx={{ - p: 0.5, - "&:hover": { - backgroundColor: "transparent", - }, - }} - disableRipple + onMouseDown={(e: any) => e.stopPropagation()} + onClick={(e: any) => e.stopPropagation()} > - + ), }, diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 8c1aa851..bc2d00b1 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -23,7 +23,7 @@ import { Marker, Tooltip as MapTooltip, } from "react-leaflet"; -import { Link } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 4cf1441d..2712c552 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -13,7 +13,7 @@ import { Tooltip, Typography, } from "@mui/material"; -import { Link } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index c03bef5b..596b4a87 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -26,7 +26,7 @@ import { import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { LineChart } from "@mui/x-charts"; import { css } from "@emotion/react"; -import { Link } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { useAuthHeader } from "react-auth-kit"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index 369a0035..d1c12877 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -13,7 +13,7 @@ import { TextField, Tooltip, } from "@mui/material"; -import { Link } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index 29de6ada..760dddba 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, ReactNode } from "react"; -import { Link } from "react-router-dom"; +import { Link } from "@tanstack/react-router"; import { DataGrid, GridColDef, GridSortModel } from "@mui/x-data-grid"; import { useDebounce } from "use-debounce"; import { useAuthUser } from "react-auth-kit"; @@ -92,10 +92,15 @@ export default function WellSelectionTable({ const links = meters.map((meter, index) => ( ({ + meter_id: meter.id, + activity_id: prev.activity_id ?? undefined, + add: prev.add ?? undefined, + tab: prev.tab ?? undefined, + q: prev.q ?? undefined, + filters: prev.filters ?? undefined, + })} > {meter.serial_number} diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index bf39838a..add72fcc 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -9,7 +9,7 @@ import { GridFilterItem, } from "@mui/x-data-grid"; import { useAuthUser } from "react-auth-kit"; -import { Link, createSearchParams, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useGetWorkOrders, useUpdateWorkOrder, @@ -25,25 +25,12 @@ import { DeleteWorkOrder } from "./DeleteWorkOrder"; import { NewWorkOrderModal } from "./NewWorkOrderModal"; export default function WorkOrdersTable() { - const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const search = useSearch({ from: "/workorders" }); const workOrderIdFilter = useMemo(() => { - // supports: - // ?work_order_id=617 - // ?work_order_id=617,618 - // ?work_order_id=617&work_order_id=618 - const all = searchParams.getAll("work_order_id"); - if (!all || all.length === 0) return null; - - const ids = all - .flatMap((v) => v.split(",")) - .map((v) => v.trim()) - .filter(Boolean) - .map((v) => Number(v)) - .filter((n) => Number.isFinite(n) && n > 0); - - return ids.length ? ids : null; - }, [searchParams]); + return search.work_order_id?.length ? search.work_order_id : null; + }, [search.work_order_id]); const [workOrderFilters, setWorkOrderFilters] = useState([ WorkOrderStatus.Open, @@ -183,10 +170,15 @@ export default function WorkOrdersTable() { renderCell: (params) => { return ( ({ + meter_id: params.row.meter_id, + activity_id: prev.activity_id ?? undefined, + add: prev.add ?? undefined, + tab: prev.tab ?? undefined, + q: prev.q ?? undefined, + filters: prev.filters ?? undefined, + })} > {params.value} @@ -234,10 +226,15 @@ export default function WorkOrdersTable() { const links = activities.map((activity, index) => ( ({ + meter_id: activity.meter_id, + activity_id: activity.id, + add: prev.add ?? undefined, + tab: prev.tab ?? undefined, + q: prev.q ?? undefined, + filters: prev.filters ?? undefined, + })} > {activity.id} @@ -302,16 +299,11 @@ export default function WorkOrdersTable() { { + e.stopPropagation(); + navigate({ to: `/manage/parts/${params.row.id}/history` }); + }} > diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ec66aad9..4b37af04 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,19 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; import path from "path"; export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); return { - plugins: [react()], + plugins: [ + tanstackRouter({ + target: "react", + autoCodeSplitting: true, + }), + react(), + ], server: { proxy: { "/api": { From 3370e63230d50157f5f99a98876ff1d10ad689aa Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 09:43:29 -0600 Subject: [PATCH 30/91] refactor(chlorides): Move view into route --- .../src/components/Modals/Region/Create.tsx | 16 +- frontend/src/routes/chlorides.tsx | 333 +++++++++++++++++- frontend/src/routes/manage/backups.tsx | 5 + frontend/src/views/Backups/BackupsView.tsx | 16 + .../src/views/Chlorides/ChloridesPlot.tsx | 14 +- .../src/views/Chlorides/ChloridesTable.tsx | 24 +- .../src/views/Chlorides/ChloridesView.tsx | 323 ----------------- frontend/src/views/Chlorides/index.ts | 3 +- 8 files changed, 392 insertions(+), 342 deletions(-) delete mode 100644 frontend/src/views/Chlorides/ChloridesView.tsx diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index 2695a2f6..a3e51bab 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -170,6 +170,14 @@ export const CreateModal = (props: CreateModalProps) => { ); }; + const hasValue = value !== null && !Number.isNaN(value); + const canSave = + !!selectedUserID && + !!selectedWellID && + !!date && + !!time && + (notSampled || hasValue); + return ( { variant="contained" color="success" onClick={onMeasurementSubmitted} - disabled={ - !!selectedUserID && - !!selectedWellID && - !!date && - !!time && - (notSampled || value !== null) - } + disabled={!canSave} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" } }} startIcon={} > diff --git a/frontend/src/routes/chlorides.tsx b/frontend/src/routes/chlorides.tsx index 33832351..08b90d8d 100644 --- a/frontend/src/routes/chlorides.tsx +++ b/frontend/src/routes/chlorides.tsx @@ -1,6 +1,333 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { ChloridesView } from "@/views"; +import { useId, useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { + FormControl, + Select, + MenuItem, + InputLabel, + Card, + CardContent, + Alert, + Button, + AlertTitle, + Grid, +} from "@mui/material"; +import { Science } from "@mui/icons-material"; +import { useMutation, useQuery } from "react-query"; +import { useAuthUser } from "react-auth-kit"; +import { useSnackbar } from "notistack"; +import dayjs, { Dayjs } from "dayjs"; +import { z } from "zod"; + +import { CreateModal, UpdateModal } from "@/components/Modals/Region"; +import { + NewRegionMeasurement, + PatchRegionMeasurement, + SecurityScope, + RegionMeasurementDTO, +} from "@/interfaces"; +import { useFetchWithAuth } from "@/hooks"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +import { emptyToNull } from "@/utils"; +import { Table, Plot } from "@/views/Chlorides"; export const Route = createFileRoute("/chlorides")({ - component: ChloridesView, + validateSearch: z.object({ + regionId: z.coerce.number().int().positive().optional(), + page: z.coerce.number().int().min(0).catch(0), + pageSize: z.coerce.number().int().min(10).max(200).catch(25), + }), + component: Chlorides, }); + +function Chlorides() { + const navigate = useNavigate(); + const { regionId } = Route.useSearch(); + const { enqueueSnackbar } = useSnackbar(); + const fetchWithAuth = useFetchWithAuth(); + const uniqueSelectId = useId(); + const [selectedMeasurement, setSelectedMeasurement] = + useState({ + levelmeasurement_id: 0, + timestamp: dayjs(), + value: 0, + submitting_user_id: 0, + well_id: 0, + }); + + const [isNewModalOpen, setIsNewModalOpen] = useState(false); + const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + + const authUser = useAuthUser(); + const isAdmin = authUser()?.user_role.security_scopes.some( + (s: SecurityScope) => s.scope_string === "admin", + ); + + const regionsQuery = useQuery<{ id: number; names: string[] }[], Error>({ + queryKey: ["regions"], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/chloride_groups", + params: { + sort_direction: "asc", + }, + }), + }); + + const manualQuery = useQuery({ + queryKey: ["chlorides", regionId], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/chlorides", + params: { chloride_group_id: regionId }, + }), + enabled: !!regionId, + }); + + const milligramPerLiterUnitId = 14; + const { mutateAsync: createChlorideLevel } = useMutation({ + mutationKey: ["regions", "creation"], + mutationFn: (body: Partial) => + fetchWithAuth({ + method: "POST", + route: "/chlorides", + body: { + timestamp: body.timestamp, + value: emptyToNull(body.value), + submitting_user_id: body.submitting_user_id, + chloride_group_id: body.region_id, + unit_id: milligramPerLiterUnitId, + well_id: body.well_id, + }, + }), + onSuccess: () => { + enqueueSnackbar("Chloride measurement created successfully", { + variant: "success", + }); + }, + onError: (err: any) => { + enqueueSnackbar( + `Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); + }, + }); + + const { mutateAsync: updateChlorideLevel } = useMutation({ + mutationKey: ["regions", "modification"], + mutationFn: (body: PatchRegionMeasurement) => + fetchWithAuth({ + method: "PATCH", + route: "/chlorides", + body: { + id: body.levelmeasurement_id, + timestamp: body.timestamp, + value: emptyToNull(body.value), + submitting_user_id: body.submitting_user_id, + chloride_group_id: regionId, + unit_id: milligramPerLiterUnitId, + well_id: body.well_id, + }, + }), + onSuccess: () => { + enqueueSnackbar("Chloride measurement updated successfully", { + variant: "success", + }); + }, + onError: (err: any) => { + enqueueSnackbar( + `Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); + }, + }); + + const { mutateAsync: deleteChlorideLevel } = useMutation({ + mutationKey: ["regions", "deletion"], + mutationFn: (levelmeasurement_id: number) => + fetchWithAuth({ + method: "DELETE", + route: "/chlorides", + params: { chloride_measurement_id: levelmeasurement_id }, + }), + onSuccess: () => { + enqueueSnackbar("Chloride measurement deleted successfully", { + variant: "success", + }); + }, + onError: (err: any) => { + enqueueSnackbar( + `Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, + { + variant: "error", + }, + ); + }, + }); + + const error = regionsQuery.isError || manualQuery.isError; + + const handleSubmitNewMeasurement = (data: Partial) => { + if (regionId) { + data.region_id = regionId; + createChlorideLevel(data, { onSuccess: () => manualQuery.refetch() }); + } + setIsNewModalOpen(false); + }; + + const handleSubmitMeasurementUpdate = () => { + updateChlorideLevel(selectedMeasurement, { + onSuccess: () => manualQuery.refetch(), + }); + setIsUpdateModalOpen(false); + }; + + const handleDeleteMeasurement = () => { + setIsUpdateModalOpen(false); + if (window.confirm("Are you sure you want to delete this measurement?")) { + deleteChlorideLevel(selectedMeasurement.levelmeasurement_id, { + onSuccess: () => manualQuery.refetch(), + }); + } + }; + + const handleMeasurementSelect = (rowdata: { + row: { + id: number; + timestamp: Dayjs; + value: number; + submitting_user: { + id: number; + }; + well: { + id: number; + }; + }; + }) => { + if (!isAdmin) return; + setSelectedMeasurement({ + levelmeasurement_id: rowdata.row.id, + timestamp: dayjs.utc(rowdata.row.timestamp).tz("America/Denver"), + value: rowdata.row.value, + submitting_user_id: rowdata.row.submitting_user.id, + well_id: rowdata.row.well.id, + }); + setIsUpdateModalOpen(true); + }; + + return ( + + + + + {error && ( + regionsQuery.refetch()} + > + Retry + + } + > + Error Loading Data + We couldn’t load chloride data. Please check your connection or + try again. + + )} + + Region + + + + + m.timestamp) ?? []} + manual_vals={ + manualQuery?.data?.map((m) => ({ + value: m.value, + well: m.well.ra_number, + })) ?? [] + } + /> + + + setIsNewModalOpen(true)} + onMeasurementSelect={handleMeasurementSelect} + /> + + + {authUser() && ( + <> + setIsNewModalOpen(false)} + handleSubmitNewMeasurement={handleSubmitNewMeasurement} + /> + setIsUpdateModalOpen(false)} + measurement={selectedMeasurement} + onUpdateMeasurement={(update) => + setSelectedMeasurement({ ...selectedMeasurement, ...update }) + } + onSubmitUpdate={handleSubmitMeasurementUpdate} + onDeleteMeasurement={handleDeleteMeasurement} + /> + + )} + + + + ); +} diff --git a/frontend/src/routes/manage/backups.tsx b/frontend/src/routes/manage/backups.tsx index 138d5ea0..56ce3941 100644 --- a/frontend/src/routes/manage/backups.tsx +++ b/frontend/src/routes/manage/backups.tsx @@ -1,8 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { BackupsView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; export const Route = createFileRoute("/manage/backups")({ + validateSearch: z.object({ + page: z.coerce.number().int().min(0).catch(0), + pageSize: z.coerce.number().int().min(10).max(200).catch(25), + }), component: () => ( diff --git a/frontend/src/views/Backups/BackupsView.tsx b/frontend/src/views/Backups/BackupsView.tsx index 7868c29a..4b2eda73 100644 --- a/frontend/src/views/Backups/BackupsView.tsx +++ b/frontend/src/views/Backups/BackupsView.tsx @@ -12,12 +12,17 @@ import { import { Storage, Refresh, Download } from "@mui/icons-material"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { useQuery } from "react-query"; +import { Route } from "@/routes/manage/backups"; +import { useNavigate } from "@tanstack/react-router"; import { BackupRow } from "@/interfaces/BackupRow"; import { useFetchWithAuth } from "@/hooks"; import { BackgroundBox, CustomCardHeader } from "@/components"; import { toYYYYMMDD, formatBytes } from "@/utils"; export const BackupsView = () => { + const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); + const fetchWithAuth = useFetchWithAuth(); const [downloading, setDownloading] = useState>({}); @@ -173,6 +178,17 @@ export const BackupsView = () => { pagination: { paginationModel: { page: 0, pageSize: 25 } }, }} pageSizeOptions={[10, 25, 50, 100]} + paginationModel={{ page, pageSize }} + onPaginationModelChange={(m) => { + navigate({ + to: "/manage/backups", + search: { + page: m.page, + pageSize: m.pageSize, + }, + replace: true, // avoid polluting history on every click + }); + }} slotProps={{ toolbar: { showQuickFilter: true, diff --git a/frontend/src/views/Chlorides/ChloridesPlot.tsx b/frontend/src/views/Chlorides/ChloridesPlot.tsx index ad0e1429..8a500006 100644 --- a/frontend/src/views/Chlorides/ChloridesPlot.tsx +++ b/frontend/src/views/Chlorides/ChloridesPlot.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; -import Plot from "react-plotly.js"; +import ReactPlot from "react-plotly.js"; import { Data } from "plotly.js"; -export const ChloridesPlot = ({ +export const Plot = ({ manual_dates, manual_vals, isLoading, @@ -34,8 +34,8 @@ export const ChloridesPlot = ({ }, [manual_dates, manual_vals]); return ( - - {isLoading ? + + {isLoading ? ( - : - - } + )} ); }; diff --git a/frontend/src/views/Chlorides/ChloridesTable.tsx b/frontend/src/views/Chlorides/ChloridesTable.tsx index 3f2c8044..87fd5499 100644 --- a/frontend/src/views/Chlorides/ChloridesTable.tsx +++ b/frontend/src/views/Chlorides/ChloridesTable.tsx @@ -7,6 +7,8 @@ import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import { useIsAuthenticated } from "react-auth-kit"; import { RegionMeasurementDTO } from "@/interfaces"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/chlorides"; dayjs.extend(utc); dayjs.extend(timezone); @@ -20,7 +22,7 @@ interface FooterExtraProps { isRegionSelected: boolean; } -export const ChloridesTable = ({ +export const Table = ({ rows, onOpenModal, isRegionSelected, @@ -44,6 +46,9 @@ export const ChloridesTable = ({ }; }) => void; }) => { + const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); + const isAuthenticated = useIsAuthenticated(); const columns: GridColDef[] = useMemo(() => { const baseCols: GridColDef[] = [ @@ -89,6 +94,23 @@ export const ChloridesTable = ({ { + navigate({ + to: "/chlorides", + search: (prev) => ({ + regionId: prev.regionId ?? undefined, + page: m.page, + pageSize: m.pageSize, + }), + replace: true, + }); + }} slots={{ footer: Footer, }} diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx deleted file mode 100644 index c8ee5fa6..00000000 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useId, useState } from "react"; -import { - FormControl, - Select, - MenuItem, - InputLabel, - Card, - CardContent, - Alert, - Button, - AlertTitle, - Grid, -} from "@mui/material"; -import { Science } from "@mui/icons-material"; -import { useMutation, useQuery } from "react-query"; -import { useAuthUser } from "react-auth-kit"; -import { useSnackbar } from "notistack"; -import dayjs, { Dayjs } from "dayjs"; - -import { CreateModal, UpdateModal } from "@/components/Modals/Region"; -import { - NewRegionMeasurement, - PatchRegionMeasurement, - SecurityScope, - RegionMeasurementDTO, -} from "@/interfaces"; -import { useFetchWithAuth } from "@/hooks"; -import { BackgroundBox, CustomCardHeader } from "@/components"; -import { emptyToNull } from "@/utils"; -import { ChloridesTable } from "./ChloridesTable"; -import { ChloridesPlot } from "./ChloridesPlot"; - -export const ChloridesView = () => { - const { enqueueSnackbar } = useSnackbar(); - const fetchWithAuth = useFetchWithAuth(); - const selectedRegionId = useId(); - const [regionId, setregionId] = useState(); - const [selectedMeasurement, setSelectedMeasurement] = - useState({ - levelmeasurement_id: 0, - timestamp: dayjs(), - value: 0, - submitting_user_id: 0, - well_id: 0, - }); - - const [isNewModalOpen, setIsNewModalOpen] = useState(false); - const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); - - const authUser = useAuthUser(); - const isAdmin = authUser()?.user_role.security_scopes.some( - (s: SecurityScope) => s.scope_string === "admin", - ); - - const { - data: regions, - isLoading: isLoadingRegions, - error: errorRegions, - refetch: refetchRegions, - } = useQuery<{ id: number; names: string[] }[], Error>({ - queryKey: ["regions"], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/chloride_groups", - params: { - sort_direction: "asc", - }, - }), - }); - - const { - data: manualMeasurements, - isLoading: isLoadingManual, - error: errorManual, - refetch: refetchManual, - } = useQuery({ - queryKey: ["chlorides", regionId], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/chlorides", - params: { chloride_group_id: regionId }, - }), - enabled: !!regionId, - }); - - const milligramPerLiterUnitId = 14; - const { mutateAsync: createChlorideLevel } = useMutation({ - mutationKey: ["regions", "creation"], - mutationFn: (body: Partial) => - fetchWithAuth({ - method: "POST", - route: "/chlorides", - body: { - timestamp: body.timestamp, - value: emptyToNull(body.value), - submitting_user_id: body.submitting_user_id, - chloride_group_id: body.region_id, - unit_id: milligramPerLiterUnitId, - well_id: body.well_id, - }, - }), - onSuccess: () => { - enqueueSnackbar("Chloride measurement created successfully", { - variant: "success", - }); - }, - onError: (err: any) => { - enqueueSnackbar( - `Failed to create chloride measurement: ${err.message ?? "Unknown error"}`, - { - variant: "error", - }, - ); - }, - }); - - const { mutateAsync: updateChlorideLevel } = useMutation({ - mutationKey: ["regions", "modification"], - mutationFn: (body: PatchRegionMeasurement) => - fetchWithAuth({ - method: "PATCH", - route: "/chlorides", - body: { - id: body.levelmeasurement_id, - timestamp: body.timestamp, - value: emptyToNull(body.value), - submitting_user_id: body.submitting_user_id, - chloride_group_id: regionId, - unit_id: milligramPerLiterUnitId, - well_id: body.well_id, - }, - }), - onSuccess: () => { - enqueueSnackbar("Chloride measurement updated successfully", { - variant: "success", - }); - }, - onError: (err: any) => { - enqueueSnackbar( - `Failed to update chloride measurement: ${err.message ?? "Unknown error"}`, - { - variant: "error", - }, - ); - }, - }); - - const { mutateAsync: deleteChlorideLevel } = useMutation({ - mutationKey: ["regions", "deletion"], - mutationFn: (levelmeasurement_id: number) => - fetchWithAuth({ - method: "DELETE", - route: "/chlorides", - params: { chloride_measurement_id: levelmeasurement_id }, - }), - onSuccess: () => { - enqueueSnackbar("Chloride measurement deleted successfully", { - variant: "success", - }); - }, - onError: (err: any) => { - enqueueSnackbar( - `Failed to delete chloride measurement: ${err.message ?? "Unknown error"}`, - { - variant: "error", - }, - ); - }, - }); - - const error = errorRegions || errorManual; - - const handleSubmitNewMeasurement = (data: Partial) => { - if (regionId) { - data.region_id = regionId; - createChlorideLevel(data, { onSuccess: () => refetchManual() }); - } - setIsNewModalOpen(false); - }; - - const handleSubmitMeasurementUpdate = () => { - updateChlorideLevel(selectedMeasurement, { - onSuccess: () => refetchManual(), - }); - setIsUpdateModalOpen(false); - }; - - const handleDeleteMeasurement = () => { - setIsUpdateModalOpen(false); - if (window.confirm("Are you sure you want to delete this measurement?")) { - deleteChlorideLevel(selectedMeasurement.levelmeasurement_id, { - onSuccess: () => refetchManual(), - }); - } - }; - - const handleMeasurementSelect = (rowdata: { - row: { - id: number; - timestamp: Dayjs; - value: number; - submitting_user: { - id: number; - }; - well: { - id: number; - }; - }; - }) => { - if (!isAdmin) return; - setSelectedMeasurement({ - levelmeasurement_id: rowdata.row.id, - timestamp: dayjs.utc(rowdata.row.timestamp).tz("America/Denver"), - value: rowdata.row.value, - submitting_user_id: rowdata.row.submitting_user.id, - well_id: rowdata.row.well.id, - }); - setIsUpdateModalOpen(true); - }; - - return ( - - - - - {error && ( - refetchRegions()} - > - Retry - - } - > - Error Loading Data - We couldn’t load chloride data. Please check your connection or - try again. - - )} - - Region - - - - - m.timestamp) ?? []} - manual_vals={ - manualMeasurements?.map((m) => ({ - value: m.value, - well: m.well.ra_number, - })) ?? [] - } - /> - - - setIsNewModalOpen(true)} - onMeasurementSelect={handleMeasurementSelect} - /> - - - {authUser() && ( - <> - setIsNewModalOpen(false)} - handleSubmitNewMeasurement={handleSubmitNewMeasurement} - /> - setIsUpdateModalOpen(false)} - measurement={selectedMeasurement} - onUpdateMeasurement={(update) => - setSelectedMeasurement({ ...selectedMeasurement, ...update }) - } - onSubmitUpdate={handleSubmitMeasurementUpdate} - onDeleteMeasurement={handleDeleteMeasurement} - /> - - )} - - - - ); -}; diff --git a/frontend/src/views/Chlorides/index.ts b/frontend/src/views/Chlorides/index.ts index 499133d3..1f43d6af 100644 --- a/frontend/src/views/Chlorides/index.ts +++ b/frontend/src/views/Chlorides/index.ts @@ -1 +1,2 @@ -export * from './ChloridesView' +export * from "./ChloridesPlot"; +export * from "./ChloridesTable"; From 5e7f44b6204e2fc08f90bb570bfbe24ea26ede7c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 14:25:05 -0600 Subject: [PATCH 31/91] fix(chlorides): Update modals to only accept chlorides types --- .../src/components/Modals/Region/Create.tsx | 112 +++-- .../src/components/Modals/Region/Update.tsx | 73 +-- .../src/interfaces/NewRegionMeasurement.ts | 2 +- frontend/src/interfaces/NewWellMeasurement.ts | 3 +- frontend/src/routes/chlorides.tsx | 2 - frontend/src/routes/monitoringwells.tsx | 421 +++++++++++++++++- .../MonitoringWells/MonitoringWellsPlot.tsx | 6 +- .../MonitoringWells/MonitoringWellsTable.tsx | 23 +- .../MonitoringWells/MonitoringWellsView.tsx | 407 ----------------- frontend/src/views/MonitoringWells/index.ts | 3 +- 10 files changed, 523 insertions(+), 529 deletions(-) delete mode 100644 frontend/src/views/MonitoringWells/MonitoringWellsView.tsx diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index a3e51bab..bc5a9fb3 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Dialog, DialogActions, @@ -22,7 +22,6 @@ import { useQuery } from "react-query"; import { MonitoredWell, NewRegionMeasurement, - NewWellMeasurement, SecurityScope, } from "@/interfaces"; import dayjs, { Dayjs } from "dayjs"; @@ -34,26 +33,19 @@ dayjs.extend(timezone); import { useGetUserList } from "@/service"; import { useFetchWithAuth } from "@/hooks"; -type CreateModalProps = - | { - mode: "region"; - region_id?: number; - open: boolean; - onClose: () => void; - handleSubmitNewMeasurement: (m: Partial) => void; - title?: string; - } - | { - mode: "well"; - open: boolean; - onClose: () => void; - handleSubmitNewMeasurement: (m: Partial) => void; - title?: string; - }; - -export const CreateModal = (props: CreateModalProps) => { - const { open, onClose, title = "Create New Measurement" } = props; - +export const CreateModal = ({ + region_id, + open, + onClose, + handleSubmitNewMeasurement, + title = "Create New Measurement", +}: { + region_id: number; + open: boolean; + onClose: () => void; + handleSubmitNewMeasurement: (m: Partial) => void; + title?: string; +}) => { const authUser = useAuthUser(); const hasAdminScope = authUser() ?.user_role.security_scopes.map( @@ -62,7 +54,7 @@ export const CreateModal = (props: CreateModalProps) => { .includes("admin"); const fetchWithAuth = useFetchWithAuth(); - const regionId = props.mode === "region" ? props.region_id : undefined; + const regionId = region_id; const { data: wells, isLoading: isLoadingWells } = useQuery< { items: MonitoredWell[] }, Error, @@ -81,7 +73,7 @@ export const CreateModal = (props: CreateModalProps) => { limit: 100, }, }), - enabled: open && props.mode === "region" && !!regionId, + enabled: open && !!regionId, select: (res) => res.items, }); @@ -104,42 +96,48 @@ export const CreateModal = (props: CreateModalProps) => { .minute(selectedTime.minute()) .second(selectedTime.second()); - props.handleSubmitNewMeasurement({ + handleSubmitNewMeasurement({ region_id: 0, // Set by parent well_id: selectedWellID as number, timestamp: combinedDateTime.toISOString(), - value: value as number, + value: notSampled ? null : value, submitting_user_id: selectedUserID as number, }); } - const UserSelection = () => { - if (hasAdminScope) { - return ( - - User - - - ); - } else { - setSelectedUserID(authUser()?.id); - return null; + useEffect(() => { + if (!open) return; + + if (!hasAdminScope) { + const id = authUser()?.id; + if (id != null) setSelectedUserID(id); } + }, [open, hasAdminScope, authUser]); + + const UserSelection = () => { + if (!hasAdminScope) return null; + + return ( + + User + + + ); }; const WellSelection = ({ region_id }: { region_id: number }) => { @@ -172,11 +170,7 @@ export const CreateModal = (props: CreateModalProps) => { const hasValue = value !== null && !Number.isNaN(value); const canSave = - !!selectedUserID && - !!selectedWellID && - !!date && - !!time && - (notSampled || hasValue); + !!selectedUserID && !!date && !!time && (notSampled || hasValue); return ( { setValue(newValue === "" ? null : Number(newValue)); }} /> - {props.mode === "region" && regionId ? ( - - ) : null} + diff --git a/frontend/src/components/Modals/Region/Update.tsx b/frontend/src/components/Modals/Region/Update.tsx index 1a0bf600..40999a15 100644 --- a/frontend/src/components/Modals/Region/Update.tsx +++ b/frontend/src/components/Modals/Region/Update.tsx @@ -31,52 +31,32 @@ import { import { useGetUserList } from "@/service"; import { useQuery } from "react-query"; import { useFetchWithAuth } from "@/hooks"; -import { - MonitoredWell, - PatchRegionMeasurement, - PatchWellMeasurement, -} from "@/interfaces"; - -type UpdateModalProps = - | { - mode: "region"; - region_id?: number; - open: boolean; - onClose: () => void; - measurement: Partial; - onUpdateMeasurement: (value: Partial) => void; - onSubmitUpdate: () => void; - onDeleteMeasurement: () => void; - title?: string; - } - | { - mode: "well"; - open: boolean; - onClose: () => void; - measurement: Partial; - onUpdateMeasurement: (value: Partial) => void; - onSubmitUpdate: () => void; - onDeleteMeasurement: () => void; - title?: string; - }; +import { MonitoredWell, PatchRegionMeasurement } from "@/interfaces"; -export const UpdateModal = (props: UpdateModalProps) => { - const { - open, - onClose, - onSubmitUpdate, - onDeleteMeasurement, - title = "Update Measurement", - } = props; +export const UpdateModal = ({ + region_id, + open, + onClose, + measurement, + onUpdateMeasurement, + onSubmitUpdate, + onDeleteMeasurement, + title = "Update Measurement", +}: { + region_id?: number; + open: boolean; + onClose: () => void; + measurement: Partial; + onUpdateMeasurement: (value: Partial) => void; + onSubmitUpdate: () => void; + onDeleteMeasurement: () => void; + title?: string; +}) => { + const regionId = region_id; const userList = useGetUserList(); const fetchWithAuth = useFetchWithAuth(); - const regionId = props.mode === "region" ? props.region_id : undefined; - - const measurement = props.measurement as any; // only for local reading convenience - const onUpdateMeasurement = props.onUpdateMeasurement as any; - const [notSampled, setNotSampled] = useState( measurement.value === undefined || measurement.value === null, ); @@ -100,14 +80,11 @@ export const UpdateModal = (props: UpdateModalProps) => { limit: 100, }, }), - enabled: open && props.mode === "region" && !!regionId, + enabled: open && !!regionId, select: (res) => res.items, }); const handleToggleNotSampled = (checked: boolean) => { - // only meaningful in region mode - if (props.mode !== "region") return; - setNotSampled(checked); if (checked) { @@ -123,10 +100,8 @@ export const UpdateModal = (props: UpdateModalProps) => { }; useEffect(() => { - if (props.mode === "region") { - setNotSampled(measurement.value == null); - } - }, [props.mode, measurement.value]); + setNotSampled(measurement.value == null); + }, [measurement.value]); return ( setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} diff --git a/frontend/src/routes/monitoringwells.tsx b/frontend/src/routes/monitoringwells.tsx index f2cf60f6..a0bedcf0 100644 --- a/frontend/src/routes/monitoringwells.tsx +++ b/frontend/src/routes/monitoringwells.tsx @@ -1,6 +1,421 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { MonitoringWellsView } from "@/views"; +import { useId, useState, useMemo } from "react"; +import { + FormControl, + Select, + MenuItem, + InputLabel, + Card, + CardContent, + ListSubheader, + useTheme, + Grid, + Alert, + Button, + AlertTitle, +} from "@mui/material"; +import { useQuery, useQueryClient } from "react-query"; +import { useAuthUser } from "react-auth-kit"; +import { enqueueSnackbar } from "notistack"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import dayjs, { Dayjs } from "dayjs"; +import { z } from "zod"; + +import { + NewWellMeasurement, + PatchWellMeasurement, + ST2Measurement, + SecurityScope, + WellMeasurementDTO, + MonitoredWell, +} from "@/interfaces"; +import { + useCreateWaterLevel, + useUpdateWaterLevel, + useDeleteWaterLevel, +} from "@/service"; +import { useFetchWithAuth, useFetchST2 } from "@/hooks"; +import { getDataStreamId, separateAndSortMonitoredWells } from "@/utils"; +import { MonitorHeart } from "@mui/icons-material"; +import { CreateModal, UpdateModal } from "@/components/Modals/MonitoredWell"; +import { CustomCardHeader, BackgroundBox } from "@/components"; +import { Table, Plot } from "@/views/MonitoringWells"; export const Route = createFileRoute("/monitoringwells")({ - component: MonitoringWellsView, + validateSearch: z.object({ + wellId: z.coerce.number().int().positive().optional(), + page: z.coerce.number().int().min(0).catch(0), + pageSize: z.coerce.number().int().min(10).max(200).catch(25), + }), + component: MonitoringWells, }); + +function MonitoringWells() { + const theme = useTheme(); + + const navigate = useNavigate(); + const { wellId } = Route.useSearch(); + const queryClient = useQueryClient(); + const fetchWithAuth = useFetchWithAuth(); + const fetchSt2 = useFetchST2(); + const uniqueSelectId = useId(); + const [selectedMeasurement, setSelectedMeasurement] = useState< + Partial + >({ + levelmeasurement_id: 0, + timestamp: dayjs(), + value: 0, + submitting_user_id: 0, + }); + + const [isNewModalOpen, setIsNewModalOpen] = useState(false); + const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + + const authUser = useAuthUser(); + const isAdmin = authUser()?.user_role.security_scopes.some( + (s: SecurityScope) => s.scope_string === "admin", + ); + + const monitoredWellsQuery = useQuery< + { items: MonitoredWell[] }, + Error, + MonitoredWell[] + >({ + queryKey: ["wells"], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/wells", + params: { + search_string: "monitoring", + sort_by: "name", + sort_direction: "asc", + }, + }), + select: (res) => res.items, + }); + + const { + data: manualMeasurements, + isLoading: isLoadingManual, + error: errorManual, + refetch: refetchManual, + } = useQuery({ + queryKey: ["manualMeasurements", wellId], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/waterlevels", + params: { well_ids: wellId }, + }), + enabled: !!wellId, + }); + + const dataStreamId = useMemo( + () => (wellId ? getDataStreamId(wellId) : undefined), + [wellId], + ); + + const { + data: st2Measurements, + isLoading: isLoadingSt2, + error: errorSt2, + } = useQuery({ + queryKey: ["st2Measurements", dataStreamId], + queryFn: () => + fetchSt2("GET", `/Datastreams(${dataStreamId})/Observations`), + enabled: !!dataStreamId, + }); + + const { + data: johnsonSensorDataMeasurements, + isLoading: isLoadingJohnsonSensorData, + error: errorJohnsonSensorData, + } = useQuery({ + queryKey: ["woodpeckers", wellId], + queryFn: () => + fetchWithAuth({ + method: "GET", + route: "/waterlevels/woodpeckers", + params: { well_id: wellId }, + }), + enabled: !!wellId && wellId === 2599, + }); + + const createMeasurement = useCreateWaterLevel(); + const updateMeasurement = useUpdateWaterLevel(() => refetchManual()); + const deleteMeasurement = useDeleteWaterLevel(); + + const error = + monitoredWellsQuery.isError || + errorManual || + errorSt2 || + errorJohnsonSensorData; + + const handleSubmitNewMeasurement = (data: Partial) => { + if (wellId) { + data.well_id = wellId; + createMeasurement.mutate(data, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["manualMeasurements", wellId], + }); + refetchManual(); + }, + }); + } + setIsNewModalOpen(false); + }; + + const handleSubmitMeasurementUpdate = () => { + updateMeasurement.mutate(selectedMeasurement, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["manualMeasurements", wellId], + }); + }, + }); + setIsUpdateModalOpen(false); + }; + + const handleDeleteMeasurement = () => { + setIsUpdateModalOpen(false); + + const id = selectedMeasurement.levelmeasurement_id; + if (!id) { + enqueueSnackbar("No measurement selected to delete.", { + variant: "warning", + }); + return; + } + + if (window.confirm("Are you sure you want to delete this measurement?")) { + deleteMeasurement.mutate(id, { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["manualMeasurements", wellId], + }); + enqueueSnackbar("Measurement deleted.", { variant: "success" }); + }, + onError: (e: any) => { + enqueueSnackbar(e?.message ?? "Failed to delete measurement.", { + variant: "error", + }); + }, + }); + } + }; + + const handleMeasurementSelect = (rowdata: { + row: { + id: number; + timestamp: Dayjs; + value: number; + submitting_user: { + id: number; + }; + }; + }) => { + if (!isAdmin) return; + setSelectedMeasurement({ + levelmeasurement_id: rowdata.row.id, + timestamp: dayjs.utc(rowdata.row.timestamp).tz("America/Denver"), + value: rowdata.row.value, + submitting_user_id: rowdata.row.submitting_user.id, + }); + setIsUpdateModalOpen(true); + }; + + const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells( + monitoredWellsQuery?.data, + ); + + return ( + + + + + {error && ( + monitoredWellsQuery.refetch()} + > + Retry + + } + > + Error Loading Data + We couldn’t load monitoring wells. Please check your connection or + try again. + + )} + + Site + + + + + m.timestamp)} + manual_vals={(Array.isArray(manualMeasurements) + ? manualMeasurements + : [] + ).map((m) => m.value)} + logger_dates={ + Array.isArray(st2Measurements) + ? (st2Measurements ?? []).map((m) => m.resultTime) + : [] + } + logger_vals={ + Array.isArray(st2Measurements) + ? st2Measurements.map((m) => m.result) + : [] + } + sensor_dates={ + Array.isArray(johnsonSensorDataMeasurements) + ? johnsonSensorDataMeasurements?.map((m) => m.timestamp) + : undefined + } + sensor_vals={ + Array.isArray(johnsonSensorDataMeasurements) + ? johnsonSensorDataMeasurements?.map((m) => m.value) + : undefined + } + /> + + +
well.id == wellId, + )} + isWellSelected={!!wellId} + onOpenModal={() => setIsNewModalOpen(true)} + onMeasurementSelect={handleMeasurementSelect} + /> + + + {authUser() && ( + <> + setIsNewModalOpen(false)} + handleSubmitNewMeasurement={handleSubmitNewMeasurement} + /> + setIsUpdateModalOpen(false)} + measurement={selectedMeasurement} + onUpdateMeasurement={(update) => + setSelectedMeasurement((prev) => ({ ...prev, ...update })) + } + onSubmitUpdate={handleSubmitMeasurementUpdate} + onDeleteMeasurement={handleDeleteMeasurement} + /> + + )} + + + + ); +} diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx index f5ac2f67..d500b3a6 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; -import Plot from "react-plotly.js"; +import ReactPlot from "react-plotly.js"; import { Data } from "plotly.js"; -export const MonitoringWellsPlot = ({ +export const Plot = ({ manual_dates, manual_vals, logger_dates, @@ -75,7 +75,7 @@ export const MonitoringWellsPlot = ({ ) : ( - void; }) => { + const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); const isAuthenticated = useIsAuthenticated(); const columns: GridColDef[] = useMemo(() => { const baseCols: GridColDef[] = [ @@ -80,6 +84,23 @@ export const MonitoringWellsTable = ({ { + navigate({ + to: "/monitoringwells", + search: (prev) => ({ + wellId: prev.wellId ?? undefined, + page: m.page, + pageSize: m.pageSize, + }), + replace: true, + }); + }} slots={{ footer: Footer, }} diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx deleted file mode 100644 index 94c050a4..00000000 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import { useId, useState, useMemo } from "react"; -import { - FormControl, - Select, - MenuItem, - InputLabel, - Card, - CardContent, - ListSubheader, - useTheme, - Grid, - Alert, - Button, - AlertTitle, -} from "@mui/material"; -import { useQuery, useQueryClient } from "react-query"; -import { useAuthUser } from "react-auth-kit"; -import { - NewWellMeasurement, - PatchWellMeasurement, - ST2Measurement, - SecurityScope, - WellMeasurementDTO, - MonitoredWell, -} from "@/interfaces"; -import { - useCreateWaterLevel, - useUpdateWaterLevel, - useDeleteWaterLevel, -} from "@/service"; -import dayjs, { Dayjs } from "dayjs"; -import { enqueueSnackbar } from "notistack"; -import { useFetchWithAuth, useFetchST2 } from "@/hooks"; -import { getDataStreamId, separateAndSortMonitoredWells } from "@/utils"; -import { MonitorHeart } from "@mui/icons-material"; -import { - CreateModal, - UpdateModal, - CustomCardHeader, - BackgroundBox, -} from "@/components"; - -import { MonitoringWellsTable } from "./MonitoringWellsTable"; -import { MonitoringWellsPlot } from "./MonitoringWellsPlot"; - -export const MonitoringWellsView = () => { - const theme = useTheme(); - - const queryClient = useQueryClient(); - const fetchWithAuth = useFetchWithAuth(); - const fetchSt2 = useFetchST2(); - const selectWellId = useId(); - const [wellId, setWellId] = useState(); - const [selectedMeasurement, setSelectedMeasurement] = useState< - Partial - >({ - levelmeasurement_id: 0, - timestamp: dayjs(), - value: 0, - submitting_user_id: 0, - }); - - const [isNewModalOpen, setIsNewModalOpen] = useState(false); - const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); - - const authUser = useAuthUser(); - const isAdmin = authUser()?.user_role.security_scopes.some( - (s: SecurityScope) => s.scope_string === "admin", - ); - - const monitoredWellsQuery = useQuery< - { items: MonitoredWell[] }, - Error, - MonitoredWell[] - >({ - queryKey: ["wells"], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/wells", - params: { - search_string: "monitoring", - sort_by: "name", - sort_direction: "asc", - }, - }), - select: (res) => res.items, - }); - - const { - data: manualMeasurements, - isLoading: isLoadingManual, - error: errorManual, - refetch: refetchManual, - } = useQuery({ - queryKey: ["manualMeasurements", wellId], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/waterlevels", - params: { well_ids: wellId }, - }), - enabled: !!wellId, - }); - - const dataStreamId = useMemo( - () => (wellId ? getDataStreamId(wellId) : undefined), - [wellId], - ); - - const { - data: st2Measurements, - isLoading: isLoadingSt2, - error: errorSt2, - } = useQuery({ - queryKey: ["st2Measurements", dataStreamId], - queryFn: () => - fetchSt2("GET", `/Datastreams(${dataStreamId})/Observations`), - enabled: !!dataStreamId, - }); - - const { - data: johnsonSensorDataMeasurements, - isLoading: isLoadingJohnsonSensorData, - error: errorJohnsonSensorData, - } = useQuery({ - queryKey: ["woodpeckers", wellId], - queryFn: () => - fetchWithAuth({ - method: "GET", - route: "/waterlevels/woodpeckers", - params: { well_id: wellId }, - }), - enabled: !!wellId && wellId === 2599, - }); - - const createMeasurement = useCreateWaterLevel(); - const updateMeasurement = useUpdateWaterLevel(() => refetchManual()); - const deleteMeasurement = useDeleteWaterLevel(); - - const error = - monitoredWellsQuery.isError || - errorManual || - errorSt2 || - errorJohnsonSensorData; - - const handleSubmitNewMeasurement = (data: Partial) => { - if (wellId) { - data.well_id = wellId; - createMeasurement.mutate(data, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["manualMeasurements", wellId], - }); - refetchManual(); - }, - }); - } - setIsNewModalOpen(false); - }; - - const handleSubmitMeasurementUpdate = () => { - updateMeasurement.mutate(selectedMeasurement, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["manualMeasurements", wellId], - }); - }, - }); - setIsUpdateModalOpen(false); - }; - - const handleDeleteMeasurement = () => { - setIsUpdateModalOpen(false); - - const id = selectedMeasurement.levelmeasurement_id; - if (!id) { - enqueueSnackbar("No measurement selected to delete.", { - variant: "warning", - }); - return; - } - - if (window.confirm("Are you sure you want to delete this measurement?")) { - deleteMeasurement.mutate(id, { - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["manualMeasurements", wellId], - }); - enqueueSnackbar("Measurement deleted.", { variant: "success" }); - }, - onError: (e: any) => { - enqueueSnackbar(e?.message ?? "Failed to delete measurement.", { - variant: "error", - }); - }, - }); - } - }; - - const handleMeasurementSelect = (rowdata: { - row: { - id: number; - timestamp: Dayjs; - value: number; - submitting_user: { - id: number; - }; - }; - }) => { - if (!isAdmin) return; - setSelectedMeasurement({ - levelmeasurement_id: rowdata.row.id, - timestamp: dayjs.utc(rowdata.row.timestamp).tz("America/Denver"), - value: rowdata.row.value, - submitting_user_id: rowdata.row.submitting_user.id, - }); - setIsUpdateModalOpen(true); - }; - - const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells( - monitoredWellsQuery?.data, - ); - - return ( - - - - - {error && ( - monitoredWellsQuery.refetch()} - > - Retry - - } - > - Error Loading Data - We couldn’t load monitoring wells. Please check your connection or - try again. - - )} - - Site - - - - - m.timestamp)} - manual_vals={(Array.isArray(manualMeasurements) - ? manualMeasurements - : [] - ).map((m) => m.value)} - logger_dates={ - Array.isArray(st2Measurements) - ? (st2Measurements ?? []).map((m) => m.resultTime) - : [] - } - logger_vals={ - Array.isArray(st2Measurements) - ? st2Measurements.map((m) => m.result) - : [] - } - sensor_dates={ - Array.isArray(johnsonSensorDataMeasurements) - ? johnsonSensorDataMeasurements?.map((m) => m.timestamp) - : undefined - } - sensor_vals={ - Array.isArray(johnsonSensorDataMeasurements) - ? johnsonSensorDataMeasurements?.map((m) => m.value) - : undefined - } - /> - - - well.id == wellId, - )} - isWellSelected={!!wellId} - onOpenModal={() => setIsNewModalOpen(true)} - onMeasurementSelect={handleMeasurementSelect} - /> - - - {authUser() && ( - <> - setIsNewModalOpen(false)} - handleSubmitNewMeasurement={handleSubmitNewMeasurement} - /> - setIsUpdateModalOpen(false)} - measurement={selectedMeasurement} - onUpdateMeasurement={(update) => - setSelectedMeasurement((prev) => ({ ...prev, ...update })) - } - onSubmitUpdate={handleSubmitMeasurementUpdate} - onDeleteMeasurement={handleDeleteMeasurement} - /> - - )} - - - - ); -}; diff --git a/frontend/src/views/MonitoringWells/index.ts b/frontend/src/views/MonitoringWells/index.ts index 43abef67..b2e83932 100644 --- a/frontend/src/views/MonitoringWells/index.ts +++ b/frontend/src/views/MonitoringWells/index.ts @@ -1 +1,2 @@ -export * from './MonitoringWellsView' +export * from "./MonitoringWellsPlot"; +export * from "./MonitoringWellsTable"; From 8c1fcdb511ed47e5a5ce1b44c894d5af8af36861 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 15:23:12 -0600 Subject: [PATCH 32/91] fix(Modals): CanSave logic on all modals --- .../Modals/MonitoredWell/Create.tsx | 56 ++++++++++++++----- .../Modals/MonitoredWell/Update.tsx | 38 +++++++++---- .../src/components/Modals/Region/Create.tsx | 11 +++- .../src/components/Modals/Region/Update.tsx | 15 +++++ frontend/src/routes/monitoringwells.tsx | 2 - 5 files changed, 92 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Modals/MonitoredWell/Create.tsx b/frontend/src/components/Modals/MonitoredWell/Create.tsx index cca993f8..5f0f217b 100644 --- a/frontend/src/components/Modals/MonitoredWell/Create.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Create.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { DialogActions, DialogContent, @@ -37,25 +37,49 @@ export const CreateModal = ({ title?: string; }) => { const authUser = useAuthUser(); + const userList = useGetUserList(); + const hasAdminScope = authUser() ?.user_role.security_scopes.map( (scope: SecurityScope) => scope.scope_string, ) .includes("admin"); - const userList = useGetUserList(); - const [value, setValue] = useState(null); - const [selectedUserID, setSelectedUserID] = useState(""); + const [valueRaw, setValueRaw] = useState(""); + const [selectedUserID, setSelectedUserID] = useState(null); const [date, setDate] = useState(dayjs.utc()); const [time, setTime] = useState(dayjs.utc()); - // Sends user entered information to the parent through callback + useEffect(() => { + if (!open) return; + + if (!hasAdminScope) { + const id = authUser()?.id; + setSelectedUserID(typeof id === "number" ? id : Number(id)); + } else { + setSelectedUserID(null); + } + }, [open, hasAdminScope]); + + const valueNum = valueRaw === "" ? NaN : Number(valueRaw); + const hasValue = Number.isFinite(valueNum); + + const canSave = + selectedUserID != null && + Number.isFinite(selectedUserID) && + selectedUserID > 0 && + date != null && + date.isValid() && + time != null && + time.isValid() && + hasValue; + function onMeasurementSubmitted() { - // default fallback: now + if (!canSave) return; + const selectedDate = date ?? dayjs(); const selectedTime = time ?? dayjs(); - // merge date + time into one object const combinedDateTime = selectedDate .hour(selectedTime.hour()) .minute(selectedTime.minute()) @@ -63,8 +87,8 @@ export const CreateModal = ({ handleSubmitNewMeasurement({ timestamp: combinedDateTime.toISOString(), - value: value as number, - submitting_user_id: selectedUserID as number, + value: valueNum, + submitting_user_id: selectedUserID, well_id: -1, // Set by parent }); } @@ -93,9 +117,6 @@ export const CreateModal = ({ ); - } else { - setSelectedUserID(authUser()?.id); - return null; } } @@ -147,10 +168,14 @@ export const CreateModal = ({ fullWidth size={"small"} type="number" - value={value} + value={valueRaw} label="Value" - onChange={(event) => - setValue(event.target.value as unknown as number) + onChange={(e) => setValueRaw(e.target.value)} + error={valueRaw !== "" && !Number.isFinite(valueNum)} + helperText={ + valueRaw !== "" && !Number.isFinite(valueNum) + ? "Enter a valid number." + : " " } /> @@ -177,6 +202,7 @@ export const CreateModal = ({ flexShrink: 0, width: { xs: "100%", sm: "auto" }, }} + disabled={!canSave} startIcon={} > Save diff --git a/frontend/src/components/Modals/MonitoredWell/Update.tsx b/frontend/src/components/Modals/MonitoredWell/Update.tsx index 5f4c7874..85b69032 100644 --- a/frontend/src/components/Modals/MonitoredWell/Update.tsx +++ b/frontend/src/components/Modals/MonitoredWell/Update.tsx @@ -33,13 +33,25 @@ export function UpdateModal({ }: { open: boolean; onClose: () => void; - measurement: PatchWellMeasurement; + measurement: Partial; onUpdateMeasurement: (value: Partial) => void; onSubmitUpdate: () => void; onDeleteMeasurement: () => void; }) { const userList = useGetUserList(); + const userIdNum = Number(measurement.submitting_user_id); + const ts = measurement.timestamp ? dayjs(measurement.timestamp as any) : null; + + const valueNum = measurement.value == null ? NaN : Number(measurement.value); + + const canSave = + Number.isFinite(userIdNum) && + userIdNum > 0 && + ts != null && + ts.isValid() && + Number.isFinite(valueNum); + return ( - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } + onChange={(dateval) => { + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null; + }} slotProps={{ textField: { size: "small", fullWidth: true, required: true }, }} @@ -108,9 +120,9 @@ export function UpdateModal({ textField: { size: "small", fullWidth: true, required: true }, }} value={measurement.timestamp} - onChange={(dateval) => - dateval ? onUpdateMeasurement({ timestamp: dateval }) : null - } + onChange={(dateval) => { + dateval ? onUpdateMeasurement({ timestamp: dateval }) : null; + }} /> + onChange={(event) => { + const rawValue: string = event.target.value; + const valueNum: number = rawValue === "" ? NaN : Number(rawValue); + onUpdateMeasurement({ - value: event.target.value as unknown as number, - }) - } + value: valueNum, + }); + }} /> @@ -152,6 +167,7 @@ export function UpdateModal({ variant="contained" color="success" onClick={onSubmitUpdate} + disabled={!canSave} startIcon={} > Update diff --git a/frontend/src/components/Modals/Region/Create.tsx b/frontend/src/components/Modals/Region/Create.tsx index bc5a9fb3..f6957aca 100644 --- a/frontend/src/components/Modals/Region/Create.tsx +++ b/frontend/src/components/Modals/Region/Create.tsx @@ -168,9 +168,16 @@ export const CreateModal = ({ ); }; - const hasValue = value !== null && !Number.isNaN(value); + const userOk = + Number.isFinite(Number(selectedUserID)) && Number(selectedUserID) > 0; + const wellOk = + Number.isFinite(Number(selectedWellID)) && Number(selectedWellID) > 0; + const dateOk = date != null && dayjs(date).isValid(); + const timeOk = time != null && dayjs(time).isValid(); + const hasValue = value !== null && Number.isFinite(value); + const canSave = - !!selectedUserID && !!date && !!time && (notSampled || hasValue); + userOk && wellOk && dateOk && timeOk && (notSampled || hasValue); return ( 0 && + ts != null && + ts.isValid() && + (notSampled || hasValue); + return ( } > Update diff --git a/frontend/src/routes/monitoringwells.tsx b/frontend/src/routes/monitoringwells.tsx index a0bedcf0..d7dc247e 100644 --- a/frontend/src/routes/monitoringwells.tsx +++ b/frontend/src/routes/monitoringwells.tsx @@ -396,13 +396,11 @@ function MonitoringWells() { {authUser() && ( <> setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> setIsUpdateModalOpen(false)} measurement={selectedMeasurement} From c522bb757f72be7eaedd05de5e79785176ea559d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 15:54:55 -0600 Subject: [PATCH 33/91] feat(Topbar): Condense btn, icon, & avatar sizes --- frontend/src/components/Topbar.tsx | 47 +++++++++++++------- frontend/src/components/TopbarUserButton.tsx | 16 ++++--- frontend/src/sidenav.tsx | 2 + 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 9667e7ef..b80d4e3b 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -11,6 +11,8 @@ import { Box, Divider, ListItemIcon, + useTheme, + useMediaQuery, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import { useNavigate } from "@tanstack/react-router"; @@ -27,6 +29,8 @@ export const Topbar = ({ onMenuClick: () => void; sx?: any; }) => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); @@ -54,11 +58,17 @@ export const Topbar = ({ - + navigate({ to: "/" })} @@ -153,6 +163,7 @@ export const Topbar = ({ ) : ( )} diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index d2bf2bd0..3d4e0c1c 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -51,8 +51,10 @@ export const TopbarUserButton = ({ return isSmallScreen ? ( {display_name ?? "Username"} From a4766fe1b14e189555718f60c53dfe50d28896f8 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 15:57:48 -0600 Subject: [PATCH 34/91] fix(index): Patch broken imports --- frontend/src/views/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index 1862084b..a73064fe 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -1,11 +1,9 @@ export * from "./Activities"; export * from "./Backups"; -export * from "./Chlorides"; export * from "./Home"; export * from "./InsufficientPermView"; export * from "./Login"; export * from "./Meters"; -export * from "./MonitoringWells"; export * from "./NotFound"; export * from "./Parts"; export * from "./Reports"; From 2a35a8946ead3e6741c7a6943fb521b6d71d4f8e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 17:28:53 -0600 Subject: [PATCH 35/91] feat(workorders): Add pagination search params --- .../Modals/WorkOrders/Create.tsx} | 16 ++-- .../src/components/Modals/WorkOrders/index.ts | 1 + .../src/components/UI/TanstackIconButton.tsx | 9 -- frontend/src/components/UI/index.ts | 1 - frontend/src/components/index.ts | 1 - frontend/src/routes/activities.tsx | 36 ++++--- frontend/src/routes/workorders.tsx | 53 +++++++++-- frontend/src/views/Parts/PartsTable.tsx | 17 ++-- .../src/views/WorkOrders/DeleteWorkOrder.tsx | 56 ----------- .../src/views/WorkOrders/WorkOrdersTable.tsx | 93 ++++++++++++++----- .../src/views/WorkOrders/WorkOrdersView.tsx | 21 ----- frontend/src/views/WorkOrders/index.ts | 2 +- 12 files changed, 159 insertions(+), 147 deletions(-) rename frontend/src/{views/WorkOrders/NewWorkOrderModal.tsx => components/Modals/WorkOrders/Create.tsx} (96%) create mode 100644 frontend/src/components/Modals/WorkOrders/index.ts delete mode 100644 frontend/src/components/UI/TanstackIconButton.tsx delete mode 100644 frontend/src/components/UI/index.ts delete mode 100644 frontend/src/views/WorkOrders/DeleteWorkOrder.tsx delete mode 100644 frontend/src/views/WorkOrders/WorkOrdersView.tsx diff --git a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx b/frontend/src/components/Modals/WorkOrders/Create.tsx similarity index 96% rename from frontend/src/views/WorkOrders/NewWorkOrderModal.tsx rename to frontend/src/components/Modals/WorkOrders/Create.tsx index ba0c9f34..cb83af7e 100644 --- a/frontend/src/views/WorkOrders/NewWorkOrderModal.tsx +++ b/frontend/src/components/Modals/WorkOrders/Create.tsx @@ -13,17 +13,15 @@ import { Save } from "@mui/icons-material"; import { MeterListDTO, NewWorkOrder } from "@/interfaces"; import { MeterSelection } from "@/components"; -interface NewWorkOrderModalProps { - open: boolean; - onClose: () => void; - submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; -} - -export function NewWorkOrderModal({ +export const Create = ({ open, onClose, submitNewWorkOrder, -}: NewWorkOrderModalProps) { +}: { + open: boolean; + onClose: () => void; + submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; +}) => { const [workOrderTitle, setWorkOrderTitle] = useState(""); const [workOrderMeter, setWorkOrderMeter] = useState< MeterListDTO | undefined @@ -123,4 +121,4 @@ export function NewWorkOrderModal({ ); -} +}; diff --git a/frontend/src/components/Modals/WorkOrders/index.ts b/frontend/src/components/Modals/WorkOrders/index.ts new file mode 100644 index 00000000..c65721e2 --- /dev/null +++ b/frontend/src/components/Modals/WorkOrders/index.ts @@ -0,0 +1 @@ +export * from "./Create"; diff --git a/frontend/src/components/UI/TanstackIconButton.tsx b/frontend/src/components/UI/TanstackIconButton.tsx deleted file mode 100644 index 3c598018..00000000 --- a/frontend/src/components/UI/TanstackIconButton.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createLink } from "@tanstack/react-router"; -import { IconButton, type ButtonProps } from "@mui/material"; -import { forwardRef } from "react"; - -export const TanstackIconButton = createLink( - forwardRef((props, ref) => { - return ; - }), -); diff --git a/frontend/src/components/UI/index.ts b/frontend/src/components/UI/index.ts deleted file mode 100644 index 2e07cf99..00000000 --- a/frontend/src/components/UI/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./TanstackIconButton"; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 12641eff..8dcbc894 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -30,7 +30,6 @@ export * from "./Topbar"; export * from "./TopbarUserButton"; export * from "./TristateToggle"; export * from "./UserSelection"; -export * from "./UI"; export * from "./WellMapLegend"; export * from "./WellSelection"; export * from "./WorkOrderSelect"; diff --git a/frontend/src/routes/activities.tsx b/frontend/src/routes/activities.tsx index 00de7680..ab7a325c 100644 --- a/frontend/src/routes/activities.tsx +++ b/frontend/src/routes/activities.tsx @@ -1,25 +1,37 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { ActivitiesView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; const firstValue = (value: unknown) => Array.isArray(value) ? value[0] : value; -const parseNumber = (value: unknown) => { - const raw = firstValue(value); +// `meter_id` / `work_order_id`: +// - allow `?meter_id=123` or `?meter_id=123&meter_id=456` (takes first) +// - allow numeric strings +// - empty -> undefined +// - invalid -> undefined +const optionalNumber = z.preprocess((val) => { + const raw = firstValue(val); if (raw === undefined || raw === null || raw === "") return undefined; - const num = Number(raw); - return Number.isFinite(num) ? num : undefined; -}; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +}, z.number().int().positive().optional()); + +// `serial_number`: +// - allow `?serial_number=ABC` or repeated param (takes first) +// - empty -> undefined +const optionalString = z.preprocess((val) => { + const raw = firstValue(val); + if (raw === undefined || raw === null || raw === "") return undefined; + return typeof raw === "string" ? raw : undefined; +}, z.string().optional()); export const Route = createFileRoute("/activities")({ - validateSearch: (search) => ({ - meter_id: parseNumber(search.meter_id), - serial_number: - typeof search.serial_number === "string" - ? search.serial_number - : undefined, - work_order_id: parseNumber(search.work_order_id), + validateSearch: z.object({ + meter_id: optionalNumber, + serial_number: optionalString, + work_order_id: optionalNumber, }), component: () => ( diff --git a/frontend/src/routes/workorders.tsx b/frontend/src/routes/workorders.tsx index ea30331e..16745519 100644 --- a/frontend/src/routes/workorders.tsx +++ b/frontend/src/routes/workorders.tsx @@ -1,22 +1,39 @@ import { createFileRoute } from "@tanstack/react-router"; -import { WorkOrdersView } from "@/views"; +import { Card, CardContent } from "@mui/material"; +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { z } from "zod"; + import { ProtectedRoute } from "@/ProtectedRoute"; +import { BackgroundBox, CustomCardHeader } from "@/components"; +import { WorkOrdersTable } from "@/views/WorkOrders"; + +/** + * Accepts: + * - ?work_order_id=1,2,3 + * - ?work_order_id=1&work_order_id=2 + * - any mix of the above + * - or undefined + */ +const numberListSchema = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + + const raw = Array.isArray(val) ? val : [val]; -const parseNumberList = (value: unknown): number[] | undefined => { - if (value === undefined || value === null || value === "") return undefined; - const raw = Array.isArray(value) ? value : [value]; - const numbers = raw + const nums = raw .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) .map((v) => String(v).trim()) .filter(Boolean) .map((v) => Number(v)) - .filter((n) => Number.isFinite(n) && n > 0); - return numbers.length ? numbers : undefined; -}; + .filter((n) => Number.isFinite(n)); + + return nums.length ? nums : undefined; +}, z.array(z.number().int().positive()).optional()); export const Route = createFileRoute("/workorders")({ - validateSearch: (search) => ({ - work_order_id: parseNumberList(search.work_order_id), + validateSearch: z.object({ + work_order_id: numberListSchema, + page: z.coerce.number().int().min(0).catch(0), + pageSize: z.coerce.number().int().min(10).max(200).catch(25), }), component: () => ( @@ -24,3 +41,19 @@ export const Route = createFileRoute("/workorders")({ ), }); + +function WorkOrdersView() { + return ( + + + + + + + + + ); +} diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index c3fe7a60..cd1a2567 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, Grid, + IconButton, InputAdornment, Stack, TextField, @@ -27,7 +28,6 @@ import { GridFooterWithButton, IncreaseQuantityModal, IsTrueChip, - TanstackIconButton, TristateToggle, } from "@/components"; @@ -72,16 +72,21 @@ export const PartsTable = ({ }} > {params.value} - e.stopPropagation()} onClick={(e: any) => e.stopPropagation()} > - - + + + + ), }, diff --git a/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx b/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx deleted file mode 100644 index a4d4155d..00000000 --- a/frontend/src/views/WorkOrders/DeleteWorkOrder.tsx +++ /dev/null @@ -1,56 +0,0 @@ -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/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index add72fcc..349d3a82 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -20,14 +20,18 @@ import { import { WorkOrderStatus } from "@/enums"; import { Box, Button, IconButton, Stack } from "@mui/material"; import { GridFooterWithButton } from "@/components"; +import { Create } from "@/components/Modals/WorkOrders"; import { MeterActivity, NewWorkOrder, SecurityScope } from "@/interfaces"; -import { DeleteWorkOrder } from "./DeleteWorkOrder"; -import { NewWorkOrderModal } from "./NewWorkOrderModal"; +import { Route } from "@/routes/workorders"; +import { useSnackbar } from "notistack"; -export default function WorkOrdersTable() { +export const WorkOrdersTable = () => { const navigate = useNavigate(); + const { page, pageSize } = Route.useSearch(); const search = useSearch({ from: "/workorders" }); + const { enqueueSnackbar } = useSnackbar(); + const workOrderIdFilter = useMemo(() => { return search.work_order_id?.length ? search.work_order_id : null; }, [search.work_order_id]); @@ -133,10 +137,29 @@ export default function WorkOrdersTable() { }; const handleDeleteClick = (id: GridRowId) => { - let deletepromise = deleteWorkOrder.mutateAsync(id as number); - deletepromise.then(() => { - //Get the updated rows - workOrderList.refetch(); + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { + enqueueSnackbar("Invalid work order ID. Delete aborted.", { + variant: "error", + }); + return; + } + + if (!window.confirm(`Are you sure you want to delete work order ${id}?`)) { + return; + } + + deleteWorkOrder.mutate(id, { + onSuccess: () => { + enqueueSnackbar(`Work order ${id} deleted successfully.`, { + variant: "success", + }); + workOrderList.refetch(); + }, + onError: (error: any) => { + enqueueSnackbar(error?.message || "Failed to delete work order.", { + variant: "error", + }); + }, }); }; @@ -296,26 +319,39 @@ export default function WorkOrdersTable() { gap={1} > {isOpen && ( + e.stopPropagation()} + onClick={(e: any) => e.stopPropagation()} + > + + + + + )} + {hasAdminScope && ( { e.stopPropagation(); - navigate({ to: `/manage/parts/${params.row.id}/history` }); + handleDeleteClick(params.id); }} > - + )} - } - deleteMessage={`Delete work order ${params.id}?`} - label="Delete" - deleteUser={() => handleDeleteClick(params.id)} - showInMenu={false} - disabled={!hasAdminScope} - /> ); }, @@ -337,6 +373,7 @@ export default function WorkOrdersTable() { disableColumnResize={false} filterModel={workOrderIdFilter?.length ? { items: [] } : undefined} initialState={{ + pagination: { paginationModel: { page: 0, pageSize: 25 } }, columns: { columnVisibilityModel: { work_order_id: false, @@ -349,6 +386,20 @@ export default function WorkOrdersTable() { ? {} // NO default filter when URL param exists : { filter: { filterModel: { items: initialFilter } } }), }} + pagination + pageSizeOptions={[10, 25, 50, 100]} + paginationModel={{ page, pageSize }} + onPaginationModelChange={(m) => { + navigate({ + to: "/workorders", + search: (prev) => ({ + work_order_id: prev.work_order_id ?? undefined, + pageSize: m.pageSize, + page: m.pageSize !== prev.pageSize ? 0 : m.page, + }), + replace: true, + }); + }} processRowUpdate={handleRowUpdate} onProcessRowUpdateError={handleProcessRowUpdateError} slots={{ footer: GridFooterWithButton }} @@ -379,11 +430,11 @@ export default function WorkOrdersTable() { }, }} /> - setIsNewWorkOrderModalOpen(false)} submitNewWorkOrder={handleNewWorkOrder} /> ); -} +}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx deleted file mode 100644 index af89586f..00000000 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Card, CardContent } from "@mui/material"; -import { FormatListBulletedOutlined } from "@mui/icons-material"; -import { BackgroundBox, CustomCardHeader } from "@/components"; - -import WorkOrdersTable from "./WorkOrdersTable"; - -export const WorkOrdersView = () => { - return ( - - - - - - - - - ); -}; diff --git a/frontend/src/views/WorkOrders/index.ts b/frontend/src/views/WorkOrders/index.ts index 33fc5515..69241b86 100644 --- a/frontend/src/views/WorkOrders/index.ts +++ b/frontend/src/views/WorkOrders/index.ts @@ -1 +1 @@ -export * from './WorkOrdersView' +export * from "./WorkOrdersTable"; From 018ba995f2538690927a70037f2eaa89aa763bf6 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 19:18:49 -0600 Subject: [PATCH 36/91] fix(workorders): Patch deep links from parts history count pg --- api/routes/activities.py | 34 +- .../components/Modals/WorkOrders/Create.tsx | 10 +- frontend/src/components/WorkOrderSelect.tsx | 4 +- frontend/src/routes/workorders.tsx | 39 ++ frontend/src/service/ApiServiceNew.ts | 29 +- frontend/src/sidenav.tsx | 16 +- .../src/views/WorkOrders/WorkOrdersTable.tsx | 431 +++++++++++------- 7 files changed, 379 insertions(+), 184 deletions(-) diff --git a/api/routes/activities.py b/api/routes/activities.py index 0ff7d738..0942c43b 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -3,9 +3,9 @@ from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError -from sqlalchemy import select, text +from sqlalchemy import select, text, or_ from datetime import datetime -from typing import List +from typing import List, Annotated from api import security from api.schemas import meter_schemas from api.models.main_models import ( @@ -678,11 +678,16 @@ def get_note_types(db: Session = Depends(get_db)): tags=["Work Orders"], ) def get_work_orders( - filter_by_status: list[WorkOrderStatus] = Query(["Open"]), + filter_by_status: Annotated[list[WorkOrderStatus], Query()] = [ + WorkOrderStatus.Open + ], start_date: datetime = Query(datetime.strptime("2024-06-01", "%Y-%m-%d")), + work_order_id: Annotated[list[int] | None, Query()] = None, + assigned_user_id: int | None = None, + q: str | None = None, db: Session = Depends(get_db), ): - query_stmt = ( + stmt = ( select(workOrders) .options( joinedload(workOrders.status), @@ -693,7 +698,26 @@ def get_work_orders( .where(workOrderStatusLU.name.in_(filter_by_status)) .where(workOrders.date_created >= start_date) ) - work_orders = db.scalars(query_stmt).all() + + if work_order_id: + stmt = stmt.where(workOrders.id.in_(work_order_id)) + + if assigned_user_id: + stmt = stmt.where(workOrders.assigned_user_id == assigned_user_id) + + if q: + q_like = f"%{q.strip()}%" + stmt = stmt.where( + or_( + workOrders.title.ilike(q_like), + workOrders.description.ilike(q_like), + workOrders.creator.ilike(q_like), + workOrders.notes.ilike(q_like), + workOrders.meter.has(Meters.serial_number.ilike(q_like)), + ) + ) + + work_orders = db.scalars(stmt).all() # grab activities separately relevant_activities = db.scalars( diff --git a/frontend/src/components/Modals/WorkOrders/Create.tsx b/frontend/src/components/Modals/WorkOrders/Create.tsx index cb83af7e..4af34cef 100644 --- a/frontend/src/components/Modals/WorkOrders/Create.tsx +++ b/frontend/src/components/Modals/WorkOrders/Create.tsx @@ -61,6 +61,8 @@ export const Create = ({ setWorkOrderTitle(""); }; + const canSave = Boolean(workOrderMeter) && workOrderTitle.trim().length > 0; + return ( - To create a new work order, please select a meter and title. Other - fields can be edited as needed after creation. + Select a meter and enter a title to create the work order. You can + update the remaining details after it’s created. + + + + + "auto"} + getRowId={(row) => row.work_order_id} + columns={columns} + initialState={{ + pagination: { paginationModel: { page: 0, pageSize: 25 } }, + columns: { + columnVisibilityModel: { + work_order_id: false, + creator: hasAdminScope, + associated_activities: hasAdminScope, + assigned_user_id: hasAdminScope, + }, }, - }, - ...(workOrderIdFilter?.length - ? {} // NO default filter when URL param exists - : { filter: { filterModel: { items: initialFilter } } }), - }} - pagination - pageSizeOptions={[10, 25, 50, 100]} - paginationModel={{ page, pageSize }} - onPaginationModelChange={(m) => { - navigate({ - to: "/workorders", - search: (prev) => ({ - work_order_id: prev.work_order_id ?? undefined, - pageSize: m.pageSize, - page: m.pageSize !== prev.pageSize ? 0 : m.page, - }), - replace: true, - }); - }} - processRowUpdate={handleRowUpdate} - onProcessRowUpdateError={handleProcessRowUpdateError} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: hasAdminScope && ( - - - - ), - }, - }} - /> + + + ), + }, + }} + /> + setIsNewWorkOrderModalOpen(false)} From b736e1e841bf3e351498c895a91b2915bdd31cda Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sat, 28 Feb 2026 21:35:32 -0600 Subject: [PATCH 37/91] feat(manage/meters): Push state into the url --- frontend/src/routes/manage/meters.tsx | 109 +++++++---- frontend/src/routes/workorders.tsx | 4 +- .../Meters/MeterHistory/MeterHistory.tsx | 177 +++++++++--------- .../Meters/MeterHistory/MeterHistoryTable.tsx | 40 +++- .../MeterHistory/SelectedActivityDetails.tsx | 4 +- .../Meters/MeterHistory/SelectedBlankCard.tsx | 28 ++- .../SelectedObservationDetails.tsx | 4 +- .../Meters/MeterSelection/MeterSelection.tsx | 53 +++--- frontend/src/views/Meters/MetersView.tsx | 128 ++++--------- .../src/views/WorkOrders/WorkOrdersTable.tsx | 2 +- 10 files changed, 285 insertions(+), 264 deletions(-) diff --git a/frontend/src/routes/manage/meters.tsx b/frontend/src/routes/manage/meters.tsx index a2c79b1e..00b42674 100644 --- a/frontend/src/routes/manage/meters.tsx +++ b/frontend/src/routes/manage/meters.tsx @@ -1,42 +1,85 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { MetersView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; -const firstValue = (value: unknown) => - Array.isArray(value) ? value[0] : value; - -const parseNumber = (value: unknown) => { - const raw = firstValue(value); - if (raw === undefined || raw === null || raw === "") return undefined; - const num = Number(raw); - return Number.isFinite(num) ? num : undefined; -}; - -const parseBoolean = (value: unknown) => { - const raw = firstValue(value); - if (raw === true || raw === "true") return true; - if (raw === false || raw === "false") return false; - return undefined; -}; - -const parseStringArray = (value: unknown) => { - if (Array.isArray(value)) { - return value.filter((v) => typeof v === "string") as string[]; - } - if (typeof value === "string" && value.length > 0) { - return value.split(",").map((v) => v.trim()).filter(Boolean); - } - return undefined; -}; +const intPosOptional = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : undefined; +}, z.number().int().positive().optional()); + +const booleanDefaultTrue = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + + if (raw === true || raw === "true" || raw === "1" || raw === 1) return true; + if (raw === false || raw === "false" || raw === "0" || raw === 0) + return false; + + return undefined; + }, z.boolean().optional()) + .default(true); + +const meterFilterEnum = z.enum([ + "installed", + "stored", + "sold", + "scrapped", + "unknown", +]); + +const filtersSchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val : [val]; + const items = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter(Boolean); + + const allowed = new Set([ + "installed", + "stored", + "sold", + "scrapped", + "unknown", + ]); + const filtered = items.filter((x) => allowed.has(x)); + + return filtered.length ? filtered : undefined; + }, z.array(meterFilterEnum).optional()) + .default(["installed"]); + +const qSchema = z.preprocess((val) => { + if (val === undefined || val === null) return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const s = String(raw).trim(); + return s.length ? s : undefined; +}, z.string().optional()); + +const tabSchema = z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + return String(raw); + }, + z.enum(["list", "map"]).optional(), + ) + .catch("list"); export const Route = createFileRoute("/manage/meters")({ - validateSearch: (search) => ({ - meter_id: parseNumber(search.meter_id), - activity_id: parseNumber(search.activity_id), - add: parseBoolean(search.add), - tab: parseNumber(search.tab), - q: typeof search.q === "string" ? search.q : undefined, - filters: parseStringArray(search.filters), + validateSearch: z.object({ + meter_id: intPosOptional, + activity_id: intPosOptional, + observation_id: intPosOptional, + add: booleanDefaultTrue, + tab: tabSchema.catch("list").default("list"), + q: qSchema, + filters: filtersSchema, }), component: () => ( diff --git a/frontend/src/routes/workorders.tsx b/frontend/src/routes/workorders.tsx index e46033d6..1af081ad 100644 --- a/frontend/src/routes/workorders.tsx +++ b/frontend/src/routes/workorders.tsx @@ -67,7 +67,9 @@ const numberListSchema = z.preprocess((val) => { export const Route = createFileRoute("/workorders")({ validateSearch: z.object({ - status: statusListSchema.default(["Open", "Review"]), + status: statusListSchema + .catch([WorkOrderStatus.Open, WorkOrderStatus.Review]) + .default([WorkOrderStatus.Open, WorkOrderStatus.Review]), assigned_user_id: assignedUserIdSchema, // no default -> stays undefined when not set q: qSchema, // no default -> stays undefined when not set work_order_id: numberListSchema, diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index a8aa9b58..0f6c5972 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -2,6 +2,13 @@ import { useState, useEffect, useMemo } from "react"; import { Box, Card, CardContent, Grid } from "@mui/material"; import { ImageOutlined } from "@mui/icons-material"; import { useNavigate, useSearch } from "@tanstack/react-router"; + +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +dayjs.extend(utc); +dayjs.extend(timezone); + import { useGetMeterHistory } from "@/service"; import { MeterHistoryDTO, @@ -10,17 +17,10 @@ import { } from "@/interfaces"; import { MeterHistoryType } from "@/enums"; import { CustomCardHeader, ImageDialog, ImagePreviewGrid } from "@/components"; - -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc); -dayjs.extend(timezone); - -import { MeterHistoryTable } from "./MeterHistoryTable"; -import { SelectedActivityDetails } from "./SelectedActivityDetails"; -import { SelectedObservationDetails } from "./SelectedObservationDetails"; -import { SelectedBlankCard } from "./SelectedBlankCard"; +import { MeterHistoryTable } from "@/views/Meters/MeterHistory/MeterHistoryTable"; +import { SelectedActivityDetails } from "@/views/Meters/MeterHistory/SelectedActivityDetails"; +import { SelectedObservationDetails } from "@/views/Meters/MeterHistory/SelectedObservationDetails"; +import { SelectedBlankCard } from "@/views/Meters/MeterHistory/SelectedBlankCard"; import { assertDefined } from "@/utils"; export const MeterHistory = ({ @@ -29,12 +29,50 @@ export const MeterHistory = ({ selectedMeterID?: number; }) => { const navigate = useNavigate(); - const [selectedHistoryItem, setSelectedHistoryItem] = useState(); - const meterHistory = useGetMeterHistory({ meter_id: selectedMeterID }); const search = useSearch({ from: "/manage/meters" }); + + const meterHistoryQuery = useGetMeterHistory({ meter_id: selectedMeterID }); + const [dialogOpen, setDialogOpen] = useState(false); const [selectedImage, setSelectedImage] = useState(null); + const selectedActivityId = search.activity_id; + const selectedObservationId = search.observation_id; + + // Derive selected item from URL + loaded data + const selectedHistoryItem = useMemo(() => { + if (!meterHistoryQuery.data) return undefined; + + if (selectedActivityId !== undefined) { + return meterHistoryQuery.data.find( + (item) => + item.history_type === MeterHistoryType.Activity && + item.history_item.id === selectedActivityId, + ); + } + + if (selectedObservationId !== undefined) { + return meterHistoryQuery.data.find( + (item) => + item.history_type === MeterHistoryType.Observation && + item.history_item.id === selectedObservationId, + ); + } + + return undefined; + }, [meterHistoryQuery.data, selectedActivityId, selectedObservationId]); + + // If URL points to an activity, scroll to history section once data is loaded + useEffect(() => { + if (!meterHistoryQuery.data) return; + if (selectedActivityId === undefined && selectedObservationId === undefined) + return; + + document + .getElementById("meter_history") + ?.scrollIntoView({ behavior: "smooth" }); + }, [meterHistoryQuery.data, selectedActivityId, selectedObservationId]); + const photos = useMemo(() => { if (selectedHistoryItem?.history_type === MeterHistoryType.Activity) { return selectedHistoryItem.photos?.map((p: any) => p.url) ?? []; @@ -42,88 +80,44 @@ export const MeterHistory = ({ return []; }, [selectedHistoryItem]); - // If there is an activity_id in the URL, set the selectedHistoryItem to the corresponding item and scroll to it - useEffect(() => { - const activity_id = search.activity_id; - - if (meterHistory.data && activity_id !== undefined) { - // Find the history item with the corresponding 'id' - const load_history_item = meterHistory.data?.find( - (item: MeterHistoryDTO) => - item.history_item.id == activity_id && - item.history_type == MeterHistoryType.Activity, - ); - if (load_history_item) { - setSelectedHistoryItem(load_history_item); - - // Find the element with the corresponding id - const element = document.getElementById("meter_history"); - if (element) { - // Scroll to the element - element.scrollIntoView({ behavior: "smooth" }); - - // Remove the hash from the URL so that the user can switch meters without scrolling - window.history.replaceState( - null, - "", - window.location.pathname + window.location.search, - ); - } else { - console.error("element not found"); - } - } - // Clear the activity_id from the URL so it doesn't interfere later - navigate({ - to: "/manage/meters", - search: (prev) => ({ - meter_id: prev.meter_id ?? undefined, - add: prev.add ?? undefined, - tab: prev.tab ?? undefined, - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, - activity_id: undefined, - }), - replace: true, - }); - } - }, [meterHistory.data, search.activity_id, navigate]); // Run the effect when meter history or the URL selection changes - - function handleDeleteItem() { - setSelectedHistoryItem(undefined); - } - - function handleSaveItem() { - //Update the meter history - meterHistory.refetch(); - } + const handleDeleteItem = () => { + // Clearing selection should clear URL too + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + activity_id: undefined, + observation_id: undefined, + }), + replace: true, + }); + }; const handleHistoryItemSelection = (historyItem: MeterHistoryDTO) => { - setSelectedHistoryItem(historyItem); if (historyItem.history_type === MeterHistoryType.Activity) { + const id = historyItem.history_item.id; + navigate({ to: "/manage/meters", search: (prev) => ({ - meter_id: prev.meter_id ?? undefined, - add: prev.add ?? undefined, - tab: prev.tab ?? undefined, - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, - activity_id: historyItem.history_item.id, - }), - }); - } else if (search.activity_id !== undefined) { - navigate({ - to: "/manage/meters", - search: (prev) => ({ - meter_id: prev.meter_id ?? undefined, - add: prev.add ?? undefined, - tab: prev.tab ?? undefined, - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, - activity_id: undefined, + ...(prev as any), + activity_id: prev.activity_id === id ? undefined : id, + observation_id: undefined, }), }); + return; } + + const id = historyItem.history_item.id; + + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + observation_id: prev.observation_id === id ? undefined : id, + activity_id: undefined, + }), + }); }; // Function to convert MeterHistoryDTO to PatchMeterActivity @@ -194,10 +188,10 @@ export const MeterHistory = ({ meterHistoryQuery.refetch()} /> - {photos && photos?.length > 0 ? ( + {photos?.length > 0 ? ( @@ -226,7 +220,7 @@ export const MeterHistory = ({ meterHistoryQuery.refetch()} /> ); }; @@ -237,7 +231,10 @@ export const MeterHistory = ({ diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index 8d222c32..b202125c 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -14,14 +14,16 @@ import { CustomCardHeader } from "@/components"; export const MeterHistoryTable = ({ onHistoryItemSelection, selectedMeterHistory, + isLoading, + selectedActivityId, + selectedObservationId, }: { - onHistoryItemSelection: Function; + onHistoryItemSelection: (item: MeterHistoryDTO) => void; selectedMeterHistory: MeterHistoryDTO[] | undefined; + isLoading: boolean; + selectedActivityId?: number; + selectedObservationId?: number; }) => { - const handleRowSelect = (rowDetails: any) => { - onHistoryItemSelection(rowDetails.row); - }; - const columns: GridColDef[] = [ { field: "date", @@ -67,6 +69,24 @@ export const MeterHistoryTable = ({ }, ]; + const rows = Array.isArray(selectedMeterHistory) ? selectedMeterHistory : []; + + // Stable row id (so selection works) + const getRowId = (row: MeterHistoryDTO) => { + if (row.history_type === MeterHistoryType.Activity) { + return `act-${row.history_item.id}`; + } + return `obs-${row.history_item.id}`; // if observations have id; adjust if not + }; + + // Selection model derived from URL activity_id + const rowSelectionModel = + selectedActivityId !== undefined + ? [`act-${selectedActivityId}`] + : selectedObservationId !== undefined + ? [`obs-${selectedObservationId}`] + : []; + return ( @@ -74,8 +94,14 @@ export const MeterHistoryTable = ({ { + onHistoryItemSelection(params.row as MeterHistoryDTO); + }} /> diff --git a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx index a355cd22..a3fe7c00 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; -import { Save, InfoOutlined } from "@mui/icons-material"; +import { Save, Construction } from "@mui/icons-material"; import { PatchActivityForm, PatchActivitySubmit, @@ -116,7 +116,7 @@ export const SelectedActivityDetails = ({ diff --git a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx index 8507c5da..58b6420a 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx @@ -1,16 +1,26 @@ -import { Grid, Card, CardContent } from "@mui/material"; -import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Card, CardContent, Typography } from "@mui/material"; import { CustomCardHeader } from "@/components"; +import { ArrowBack, NewReleases } from "@mui/icons-material"; -// A blank card to display when no history item is selected export const SelectedBlankCard = () => { return ( - - - - - Select a history item to view details - + + + + + + Select an activity or observation to view its details. + ); diff --git a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx index 6491e47b..aeaa7f7a 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx @@ -3,7 +3,7 @@ import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { enqueueSnackbar } from "notistack"; import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; -import { Save, InfoOutlined } from "@mui/icons-material"; +import { Save, Biotech } from "@mui/icons-material"; import { PatchObservationForm, PatchObservationSubmit, @@ -118,7 +118,7 @@ export const SelectedObservationDetails = ({ diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index 179ceb12..9e2c4513 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -14,6 +14,9 @@ import { import { FormatListBulletedOutlined, Search } from "@mui/icons-material"; import { MeterStatusNames } from "@/enums"; import { CustomCardHeader, TabPanel } from "@/components"; +import { useMemo } from "react"; + +type MeterFilterKey = "installed" | "stored" | "sold" | "scrapped" | "unknown"; export const MeterSelection = ({ onMeterSelection, @@ -25,50 +28,38 @@ export const MeterSelection = ({ meterFilterButtons, onFilterButtonsChange, }: { - onMeterSelection: Function; - setMeterAddMode: Function; + onMeterSelection: (meterId?: number) => void; + setMeterAddMode: (addMode: boolean) => void; currentTabIndex: number; onTabChange: (index: number) => void; meterSearchQuery: string; onSearchQueryChange: (query: string) => void; - meterFilterButtons: string[]; - onFilterButtonsChange: (filters: string[]) => void; + meterFilterButtons: MeterFilterKey[]; + onFilterButtonsChange: (filters: MeterFilterKey[]) => void; }) => { const handleTabChange = (_: React.SyntheticEvent, newTabIndex: number) => onTabChange(newTabIndex); const handleFilterSelect = ( _: React.MouseEvent, - newFilters: string[], + newFilters: MeterFilterKey[], ) => { - if (newFilters.length === 0) { - newFilters.push("installed"); - } - - onFilterButtonsChange(newFilters); + onFilterButtonsChange(newFilters.length ? newFilters : ["installed"]); }; - const meterFilters: MeterStatusNames[] = (() => { - // Update the meterFilters based on the selected filter buttons - let updatedMeterFilters: MeterStatusNames[] = []; - if (meterFilterButtons.includes("installed")) { - updatedMeterFilters.push(MeterStatusNames.Installed); - } - if (meterFilterButtons.includes("stored")) { - updatedMeterFilters.push(MeterStatusNames.Warehouse); - } - if (meterFilterButtons.includes("sold")) { - updatedMeterFilters.push(MeterStatusNames.Sold); - } - if (meterFilterButtons.includes("scrapped")) { - updatedMeterFilters.push(MeterStatusNames.Scrapped); - updatedMeterFilters.push(MeterStatusNames.Returned); - } - if (meterFilterButtons.includes("unknown")) { - updatedMeterFilters.push(MeterStatusNames.Unknown); - } - return updatedMeterFilters; - })(); + const meterFilters: MeterStatusNames[] = useMemo(() => { + const out: MeterStatusNames[] = []; + if (meterFilterButtons.includes("installed")) + out.push(MeterStatusNames.Installed); + if (meterFilterButtons.includes("stored")) + out.push(MeterStatusNames.Warehouse); + if (meterFilterButtons.includes("sold")) out.push(MeterStatusNames.Sold); + if (meterFilterButtons.includes("scrapped")) + out.push(MeterStatusNames.Scrapped, MeterStatusNames.Returned); + if (meterFilterButtons.includes("unknown")) + out.push(MeterStatusNames.Unknown); + return out; + }, [meterFilterButtons]); return ( diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index 4fc023a3..29918770 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -1,144 +1,96 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { Grid } from "@mui/material"; -import { MeterSelection } from "./MeterSelection/MeterSelection"; -import { MeterDetailsFields } from "./MeterDetailsFields"; -import { MeterHistory } from "./MeterHistory/MeterHistory"; - +import { MeterSelection } from "@/views/Meters/MeterSelection/MeterSelection"; +import { MeterDetailsFields } from "@/views/Meters/MeterDetailsFields"; +import { MeterHistory } from "@/views/Meters/MeterHistory/MeterHistory"; import { BackgroundBox } from "@/components"; -// Main view for the Meters page -// URL state is used to pre-select a meter and history details +const tabToIndex = (tab: "list" | "map") => (tab === "list" ? 0 : 1); +const indexToTab = (i: number): "list" | "map" => (i === 1 ? "map" : "list"); + export const MetersView = () => { const navigate = useNavigate(); const search = useSearch({ from: "/manage/meters" }); - const [selectedMeter, setSelectedMeter] = useState( - search.meter_id, - ); - const [meterAddMode, setMeterAddMode] = useState( - search.add ?? false, - ); - const [currentTabIndex, setCurrentTabIndex] = useState( - search.tab ?? 0, - ); - const [meterSearchQuery, setMeterSearchQuery] = useState( - search.q ?? "", - ); - const [meterFilterButtons, setMeterFilterButtons] = useState( - search.filters && search.filters.length > 0 - ? search.filters - : ["installed"], - ); + const selectedMeter = search.meter_id; + const meterAddMode = search.add; + const currentTab = search.tab; + const meterSearchQuery = search.q ?? ""; + const meterFilterButtons = search.filters; + // If a meter is selected, force add mode off (and reflect in URL) useEffect(() => { - setSelectedMeter(search.meter_id); - setMeterAddMode(search.add ?? false); - setCurrentTabIndex(search.tab ?? 0); - setMeterSearchQuery(search.q ?? ""); - setMeterFilterButtons( - search.filters && search.filters.length > 0 - ? search.filters - : ["installed"], - ); - }, [search.add, search.filters, search.meter_id, search.q, search.tab]); + if (!selectedMeter) return; + if (search.add === false) return; - //Always set the meterAddMode to false when a new meter is selected - useEffect(() => { - if (selectedMeter) { - setMeterAddMode(false); - } - }, [selectedMeter]); - - useEffect(() => { - if (selectedMeter && search.add) { - navigate({ - to: "/manage/meters", - search: (prev) => ({ - meter_id: prev.meter_id ?? undefined, - tab: prev.tab ?? undefined, - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, - activity_id: prev.activity_id ?? undefined, - add: undefined, - }), - replace: true, - }); - } + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + add: false, + }), + replace: true, + }); }, [selectedMeter, search.add, navigate]); const handleMeterSelection = (meterId?: number) => { - setSelectedMeter(meterId); navigate({ to: "/manage/meters", search: (prev) => ({ - tab: prev.tab ?? undefined, - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, + ...(prev as any), meter_id: meterId, activity_id: undefined, + observation_id: undefined, + // selecting a meter turns add off add: meterId ? false : prev.add, }), }); }; const handleMeterAddMode = (addMode: boolean) => { - setMeterAddMode(addMode); navigate({ to: "/manage/meters", search: (prev) => ({ - tab: prev.tab ?? undefined, - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, - add: addMode ? true : undefined, + ...(prev as any), + add: addMode, + // entering add mode clears meter selection + activity + observation meter_id: addMode ? undefined : prev.meter_id, activity_id: addMode ? undefined : prev.activity_id, + observation_id: addMode ? undefined : prev.observation_id, }), }); }; const handleTabChange = (tabIndex: number) => { - setCurrentTabIndex(tabIndex); navigate({ to: "/manage/meters", search: (prev) => ({ - q: prev.q ?? undefined, - filters: prev.filters ?? undefined, - add: prev.add ? true : undefined, - meter_id: prev.meter_id ?? undefined, - activity_id: prev.activity_id ?? undefined, - tab: tabIndex ? tabIndex : undefined, + ...(prev as any), + tab: indexToTab(tabIndex), }), }); }; const handleSearchQueryChange = (query: string) => { - setMeterSearchQuery(query); navigate({ to: "/manage/meters", search: (prev) => ({ - tab: prev.tab ?? undefined, - filters: prev.filters ?? undefined, - add: prev.add ? true : undefined, - meter_id: prev.meter_id ?? undefined, - activity_id: prev.activity_id ?? undefined, - q: query ? query : undefined, + ...(prev as any), + q: query.trim() ? query : undefined, }), }); }; - const handleFilterButtonsChange = (filters: string[]) => { - setMeterFilterButtons(filters); + const handleFilterButtonsChange = ( + filters: Array<"installed" | "stored" | "sold" | "scrapped" | "unknown">, + ) => { navigate({ to: "/manage/meters", search: (prev) => ({ - tab: prev.tab ?? undefined, - add: prev.add ? true : undefined, - meter_id: prev.meter_id ?? undefined, - activity_id: prev.activity_id ?? undefined, - q: prev.q ?? undefined, - filters: filters.length ? filters : undefined, + ...(prev as any), + filters: filters.length ? filters : ["installed"], }), }); }; @@ -154,7 +106,7 @@ export const MetersView = () => { { - + Date: Sat, 28 Feb 2026 22:19:25 -0600 Subject: [PATCH 38/91] fix(manage/meters): Patch url pagination --- frontend/src/routes/manage/meters.tsx | 9 ++++ .../Meters/MeterHistory/MeterHistory.tsx | 10 ++++- .../Meters/MeterHistory/MeterHistoryTable.tsx | 23 ++++++++++- .../Meters/MeterHistory/SelectedBlankCard.tsx | 34 +++++++++++---- .../MeterSelection/MeterSelectionTable.tsx | 41 +++++++++++++------ 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/frontend/src/routes/manage/meters.tsx b/frontend/src/routes/manage/meters.tsx index 00b42674..f9b7cc50 100644 --- a/frontend/src/routes/manage/meters.tsx +++ b/frontend/src/routes/manage/meters.tsx @@ -71,6 +71,9 @@ const tabSchema = z ) .catch("list"); +const pageSchema = z.coerce.number().int().min(0).catch(0); +const pageSizeSchema = z.coerce.number().int().min(10).max(200).catch(25); + export const Route = createFileRoute("/manage/meters")({ validateSearch: z.object({ meter_id: intPosOptional, @@ -80,6 +83,12 @@ export const Route = createFileRoute("/manage/meters")({ tab: tabSchema.catch("list").default("list"), q: qSchema, filters: filtersSchema, + // all meters list pagination + m_page: pageSchema, + m_pageSize: pageSizeSchema, + // meter history pagination + h_page: pageSchema, + h_pageSize: pageSizeSchema, }), component: () => ( diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index 0f6c5972..2133ace5 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -178,8 +178,16 @@ export const MeterHistory = ({ return observation_details; } + const hasMeter = Boolean(search.meter_id); + const hasSelection = + Boolean(search.activity_id) || Boolean(search.observation_id); + const getDetailsCard = (historyItem?: MeterHistoryDTO): JSX.Element => { - if (!historyItem) return ; + if (!hasMeter) return <>; + + if (!hasSelection) return ; + + if (!historyItem) return ; if (historyItem.history_type === MeterHistoryType.Activity) { return ( diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index b202125c..c2f41e67 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -1,6 +1,8 @@ import { Card, CardContent } from "@mui/material"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import HistoryIcon from "@mui/icons-material/History"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/meters"; +import { History } from "@mui/icons-material"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; @@ -24,6 +26,9 @@ export const MeterHistoryTable = ({ selectedActivityId?: number; selectedObservationId?: number; }) => { + const search = Route.useSearch(); + const navigate = useNavigate(); + const columns: GridColDef[] = [ { field: "date", @@ -89,7 +94,7 @@ export const MeterHistoryTable = ({ return ( - + { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + h_pageSize: m.pageSize, + h_page: m.pageSize !== (prev as any).h_pageSize ? 0 : m.page, + }), + replace: true, + }); + }} rowSelectionModel={rowSelectionModel} disableRowSelectionOnClick={false} onRowClick={(params) => { diff --git a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx index 58b6420a..15a6b2c8 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx @@ -1,26 +1,44 @@ -import { Card, CardContent, Typography } from "@mui/material"; +import { Card, CardContent, CircularProgress, Typography } from "@mui/material"; import { CustomCardHeader } from "@/components"; -import { ArrowBack, NewReleases } from "@mui/icons-material"; +import { ArrowBack, CloudSync, NewReleases } from "@mui/icons-material"; -export const SelectedBlankCard = () => { +export const SelectedBlankCard = ({ + isLoading = false, +}: { + isLoading?: boolean; +}) => { return ( - + - - - Select an activity or observation to view its details. - + {isLoading ? ( + <> + + Loading details… + + ) : ( + <> + + + Select an activity or observation to view its details. + + + )} ); diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 250b2a9c..8fc9ac20 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -4,6 +4,8 @@ import { Box, Button, Stack } from "@mui/material"; import { DataGrid, GridSortModel, GridColDef } from "@mui/x-data-grid"; import { Add } from "@mui/icons-material"; import { useAuthUser } from "react-auth-kit"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/meters"; import { MeterListQueryParams, SecurityScope } from "@/interfaces"; import { SortDirection, MeterSortByField, MeterStatusNames } from "@/enums"; import { useGetMeterList } from "@/service/ApiServiceNew"; @@ -22,7 +24,11 @@ export const MeterSelectionTable = ({ setMeterAddMode, meterStatusFilter, }: MeterSelectionTableProps) => { + const search = Route.useSearch(); + const navigate = useNavigate(); + const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); + const [meterListQueryParams, setMeterListQueryParams] = useState({ search_string: "", @@ -32,11 +38,8 @@ export const MeterSelectionTable = ({ limit: 25, offset: 0, }); - const [gridSortModel, setGridSortModel] = useState(); - const [paginationModel, setPaginationModel] = useState({ - pageSize: 25, - page: 0, - }); + + const [gridSortModel, setGridSortModel] = useState([]); const [gridRowCount, setGridRowCount] = useState(100); const authUser = useAuthUser(); @@ -85,15 +88,16 @@ export const MeterSelectionTable = ({ MeterSortByField.SerialNumber, sort_direction: (gridSortModel?.[0]?.sort as SortDirection) ?? SortDirection.Ascending, - limit: paginationModel.pageSize, - offset: paginationModel.page * paginationModel.pageSize, + limit: search.m_pageSize, + offset: search.m_page * search.m_pageSize, }; setMeterListQueryParams(newParams); }, [ meterSearchQueryDebounced, gridSortModel, - paginationModel, meterStatusFilter, + search.m_page, + search.m_pageSize, ]); useEffect(() => { @@ -110,13 +114,26 @@ export const MeterSelectionTable = ({ rows={meterList.data?.items ?? []} loading={meterList.isPreviousData || meterList.isLoading} columns={meterTableColumns} - sortingMode="server" - onSortModelChange={setGridSortModel} onRowClick={(selectedRow) => onMeterSelection(selectedRow.row.id)} keepNonExistentRowsSelected + sortingMode="server" + sortModel={gridSortModel} + onSortModelChange={setGridSortModel} + pagination paginationMode="server" - paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} + paginationModel={{ page: search.m_page, pageSize: search.m_pageSize }} + pageSizeOptions={[10, 25, 50, 100]} + onPaginationModelChange={(m) => { + navigate({ + to: "/manage/meters", + search: (prev) => ({ + ...(prev as any), + m_pageSize: m.pageSize, + m_page: m.pageSize !== (prev as any).m_pageSize ? 0 : m.page, + }), + replace: true, + }); + }} rowCount={gridRowCount} disableColumnMenu={true} slots={{ footer: GridFooterWithButton }} From a3bbe47b45c5113897180adc42426474a21a360c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Sun, 1 Mar 2026 21:37:34 -0600 Subject: [PATCH 39/91] fix(manage/wells): Broken URL state logic --- api/routes/wells.py | 22 +++++ .../components/RHControlled/ControlledDMS.tsx | 2 + frontend/src/routes/manage/wells.tsx | 52 +++++++++++ frontend/src/service/ApiServiceNew.ts | 20 ++++ .../views/WellManagement/WellDetailsCard.tsx | 16 +++- .../WellManagement/WellManagementView.tsx | 25 ++--- .../views/WellManagement/WellSelectionMap.tsx | 21 ++++- .../WellManagement/WellSelectionTable.tsx | 93 ++++++++++++------- .../src/views/WellManagement/WellsTable.tsx | 69 +++++++++----- 9 files changed, 240 insertions(+), 80 deletions(-) diff --git a/api/routes/wells.py b/api/routes/wells.py index 91dfe8c3..47613e56 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -55,6 +55,28 @@ def get_well_status_types( return db.scalars(select(WellStatus)).all() +@public_well_router.get( + "/wells/{well_id}", + response_model=well_schemas.WellResponse, + tags=["Wells"], +) +def get_well_by_id(well_id: int, db: Session = Depends(get_db)): + stmt = ( + select(Wells) + .options( + joinedload(Wells.location), + joinedload(Wells.use_type), + joinedload(Wells.meters), + joinedload(Wells.well_status), + ) + .where(Wells.id == well_id) + ) + well = db.scalars(stmt).first() + if not well: + raise HTTPException(status_code=404, detail="Well not found") + return well + + @public_well_router.get( "/wells", response_model=LimitOffsetPage[well_schemas.WellResponse], diff --git a/frontend/src/components/RHControlled/ControlledDMS.tsx b/frontend/src/components/RHControlled/ControlledDMS.tsx index 93b84806..05430e50 100644 --- a/frontend/src/components/RHControlled/ControlledDMS.tsx +++ b/frontend/src/components/RHControlled/ControlledDMS.tsx @@ -115,6 +115,8 @@ function DMSInput({ dimension_type, value, onChange }: DMSInputProps) { value={dms_string} onChange={handleUpdate} onBlur={handleBlur} + fullWidth + size="small" InputProps={{ inputComponent: DMSFormatCustom as any, }} diff --git a/frontend/src/routes/manage/wells.tsx b/frontend/src/routes/manage/wells.tsx index e66fd2f0..2045ca4c 100644 --- a/frontend/src/routes/manage/wells.tsx +++ b/frontend/src/routes/manage/wells.tsx @@ -1,8 +1,60 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { WellManagementView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; +const intPosOptional = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : undefined; +}, z.number().int().positive().optional()); + +const booleanDefaultTrue = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + + if (raw === true || raw === "true" || raw === "1" || raw === 1) return true; + if (raw === false || raw === "false" || raw === "0" || raw === 0) + return false; + + return undefined; + }, z.boolean().optional()) + .catch(true) + .default(true); + +const qSchema = z.preprocess((val) => { + if (val === undefined || val === null) return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const s = String(raw).trim(); + return s.length ? s : undefined; +}, z.string().optional()); + +const tabSchema = z + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const s = String(raw); + return s === "list" || s === "map" ? s : "list"; + }, + z.enum(["list", "map"]).optional(), + ) + .catch("list") + .default("list"); + export const Route = createFileRoute("/manage/wells")({ + validateSearch: z + .object({ + tab: tabSchema, + add: booleanDefaultTrue, + q: qSchema, + well_id: intPosOptional, + page: z.coerce.number().int().min(0).catch(0), + pageSize: z.coerce.number().int().min(10).max(200).catch(25), + }) + .passthrough(), component: () => ( diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 4f5b6618..2b50cb4f 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -421,6 +421,26 @@ export function useGetPropertyTypes() { ); } +export function useGetWellById(well_id?: number) { + const route = "wells"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery( + [route, "detail", well_id], + () => + GETFetch( + `${route}/${well_id}`, + undefined, + authHeader(), + signOut, + navigate, + ), + { enabled: !!well_id }, + ); +} + export function useGetWells(params: WellListQueryParams | undefined) { const route = "wells"; const authHeader = useAuthHeader(); diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index b38ade72..df0f5f05 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -10,6 +10,7 @@ import { FormControlLabel, Grid, Stack, + Typography, } from "@mui/material"; import { Add, Edit, Save, SaveAs } from "@mui/icons-material"; import { useAuthUser } from "react-auth-kit"; @@ -269,11 +270,20 @@ export const WellDetailsCard = ({ -

Well Location -

+
{ - const [wellAddMode, setWellAddMode] = useState(true); - const [selectedWell, setSelectedWell] = useState(); - - useEffect(() => { - if (selectedWell) setWellAddMode(false); - }, [selectedWell]); + const search = Route.useSearch(); + const selectedWellQuery = useGetWellById(search.well_id); return ( - + diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index b97a8732..18310120 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -2,6 +2,8 @@ import { useEffect } from "react"; import { useDebounce } from "use-debounce"; import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; import { Box, Typography } from "@mui/material"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/wells"; import { useGetWellLocations } from "@/service"; import { Well } from "@/interfaces"; import { @@ -21,12 +23,12 @@ import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; export default function WellSelectionMap({ - setSelectedWell, wellSearchQueryProp, }: { wellSearchQueryProp: string; - setSelectedWell: Function; }) { + const navigate = useNavigate(); + const [wellSearchDebounced] = useDebounce(wellSearchQueryProp, 250); const wellQuery = useGetWellLocations(wellSearchDebounced); @@ -38,6 +40,19 @@ export default function WellSelectionMap({ const wellMarkers = wellQuery.data?.pages.flat() ?? []; + const handleSelectWell = (well: Well) => { + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + well_id: well.id, + add: false, + tab: "map", + }), + replace: true, + }); + }; + return ( <> setSelectedWell(well), + click: () => handleSelectWell(well), }} icon={getWellIcon(well)} > diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index 760dddba..b7aae41c 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -1,10 +1,12 @@ -import { useEffect, useState, ReactNode } from "react"; +import { useEffect, useState, ReactNode, useMemo } from "react"; import { Link } from "@tanstack/react-router"; import { DataGrid, GridColDef, GridSortModel } from "@mui/x-data-grid"; import { useDebounce } from "use-debounce"; import { useAuthUser } from "react-auth-kit"; import { Box, Button, Stack } from "@mui/material"; import { Add } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/wells"; import { SecurityScope, Well, WellListQueryParams } from "@/interfaces"; import { useGetWells } from "@/service"; import { SortDirection, WellSortByField } from "@/enums"; @@ -18,25 +20,31 @@ declare module "@mui/x-data-grid" { } export default function WellSelectionTable({ - setSelectedWell, wellSearchQueryProp, - setWellAddMode, }: { - setSelectedWell: Function; - setWellAddMode: Function; wellSearchQueryProp: string; }) { + const navigate = useNavigate(); + const search = Route.useSearch(); + const [wellSearchQueryDebounced] = useDebounce(wellSearchQueryProp, 250); - const [wellListQueryParams, setWellListQueryParams] = - useState(); const [gridSortModel, setGridSortModel] = useState(); - const [paginationModel, setPaginationModel] = useState({ - pageSize: 25, - page: 0, - }); const [gridRowCount, setGridRowCount] = useState(100); - const wellsList = useGetWells(wellListQueryParams); + const queryParams = useMemo( + () => ({ + search_string: wellSearchQueryDebounced || undefined, + sort_by: + (gridSortModel?.[0]?.field as WellSortByField) ?? WellSortByField.Name, + sort_direction: + (gridSortModel?.[0]?.sort as SortDirection) ?? SortDirection.Ascending, + limit: search.pageSize, + offset: search.page * search.pageSize, + }), + [wellSearchQueryDebounced, gridSortModel, search.page, search.pageSize], + ); + + const wellsList = useGetWells(queryParams); const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -111,19 +119,6 @@ export default function WellSelectionTable({ }, }, ]; - // Filter rows based on query params - useEffect(() => { - const newParams = { - search_string: wellSearchQueryDebounced, - sort_by: gridSortModel?.at(0)?.field ?? WellSortByField.Name, - sort_direction: - (gridSortModel?.at(0)?.sort as SortDirection) ?? - SortDirection.Ascending, - limit: paginationModel.pageSize, - offset: paginationModel.page * paginationModel.pageSize, - }; - setWellListQueryParams(newParams); - }, [wellSearchQueryDebounced, gridSortModel, paginationModel]); useEffect(() => { setGridRowCount(wellsList.data?.total ?? 0); // Update the well count when new list is recieved from API @@ -137,22 +132,44 @@ export default function WellSelectionTable({ row.id} + rowSelectionModel={search.well_id ? [search.well_id] : []} loading={wellsList.isPreviousData || wellsList.isLoading} columns={cols} sortingMode="server" - paginationMode="server" disableColumnMenu keepNonExistentRowsSelected onRowClick={(selectedRow) => { - setSelectedWell( - wellsList.data?.items.find( - (well: Well) => well.id == selectedRow.row.id, - ), + const well = wellsList.data?.items.find( + (well: Well) => well.id == selectedRow.row.id, ); + + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + well_id: well?.id, + add: false, + }), + replace: true, + }); }} onSortModelChange={setGridSortModel} - paginationModel={paginationModel} - onPaginationModelChange={setPaginationModel} + pagination + paginationMode="server" + paginationModel={{ page: search.page, pageSize: search.pageSize }} + pageSizeOptions={[10, 25, 50, 100]} + onPaginationModelChange={(m) => { + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + pageSize: m.pageSize, + page: m.pageSize !== (prev as any).pageSize ? 0 : m.page, + }), + replace: true, + }); + }} rowCount={gridRowCount} slots={{ footer: GridFooterWithButton }} slotProps={{ @@ -171,7 +188,17 @@ export default function WellSelectionTable({ +
From 290ba918f54f7c13edbc60b6cc5aa88feb3ea9a7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Mar 2026 10:41:30 -0600 Subject: [PATCH 43/91] feat(reports/maintenance): Add URL state controll to report pg --- api/routes/activities.py | 8 +- frontend/src/config.ts | 6 + frontend/src/routes/reports/maintenance.tsx | 59 +++++ .../src/views/Reports/Maintenance/index.tsx | 224 +++++++++++++----- 4 files changed, 242 insertions(+), 55 deletions(-) diff --git a/api/routes/activities.py b/api/routes/activities.py index 0942c43b..becca741 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter, Query, File, UploadFile, Form from fastapi.exceptions import HTTPException from fastapi.responses import StreamingResponse -from sqlalchemy.orm import Session, joinedload +from sqlalchemy.orm import Session, joinedload, undefer from sqlalchemy.exc import IntegrityError from sqlalchemy import select, text, or_ from datetime import datetime @@ -622,7 +622,11 @@ def get_activity_types( tags=["Activities"], ) def get_users(db: Session = Depends(get_db)): - return db.scalars(select(Users).where(Users.disabled == False)).all() + return db.scalars( + select(Users) + .options(undefer(Users.user_role_id)) + .where(Users.disabled == False) + ).all() @activity_router.get( diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 5c904958..ae523f8f 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1 +1,7 @@ export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export const ROLE_IDS = { + TECHNICIAN: 1, + ADMIN: 2, + OSE: 3, +}; diff --git a/frontend/src/routes/reports/maintenance.tsx b/frontend/src/routes/reports/maintenance.tsx index bf8d7c49..884c912b 100644 --- a/frontend/src/routes/reports/maintenance.tsx +++ b/frontend/src/routes/reports/maintenance.tsx @@ -1,8 +1,67 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { MaintenanceReportView } from "@/views/Reports/Maintenance"; import { ProtectedRoute } from "@/ProtectedRoute"; +const isoDate = z + .preprocess((val) => { + const raw = Array.isArray(val) ? val[0] : val; + if (raw == null || raw === "") return undefined; + const s = String(raw).trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : undefined; + }, z.string().optional()) + .optional(); + +const intNonNeg = z.preprocess((val) => { + const raw = Array.isArray(val) ? val[0] : val; + if (raw == null || raw === "") return undefined; + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : undefined; +}, z.number().int().nonnegative().optional()); + +const pageSizeSchema = z.preprocess((val) => { + const raw = Array.isArray(val) ? val[0] : val; + if (raw == null || raw === "") return undefined; + const n = Number(raw); + const allowed = new Set([5, 10, 25, 50, 100]); + return Number.isInteger(n) && allowed.has(n) ? n : undefined; +}, z.number().int().optional()); + +const technicianIdsSchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return []; + const raw = Array.isArray(val) ? val : [val]; + + const items = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => Number(String(v).trim())) + .filter((n) => Number.isInteger(n)); + + // unique + return Array.from(new Set(items)); + }, z.array(z.number().int())) + .catch([]); + +const trssSchema = z + .preprocess((val) => { + const raw = Array.isArray(val) ? val[0] : val; + if (raw == null) return undefined; + const s = String(raw).trim(); + return s.length ? s : undefined; + }, z.string().optional()) + .catch(undefined); + export const Route = createFileRoute("/reports/maintenance")({ + validateSearch: z.object({ + from: isoDate.catch(undefined), + to: isoDate.catch(undefined), + trss: trssSchema, + + technicians: technicianIdsSchema, + + page: intNonNeg.catch(0).default(0), + pageSize: pageSizeSchema.catch(5).default(5), + }), component: () => ( diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 2712c552..848c81ed 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useAuthHeader } from "react-auth-kit"; import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; import { @@ -13,11 +13,12 @@ import { Tooltip, Typography, } from "@mui/material"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; +import { Route } from "@/routes/reports/maintenance"; import dayjs, { Dayjs } from "dayjs"; import { PieChart } from "@mui/x-charts"; import { @@ -33,18 +34,14 @@ import { ControlledTextbox, CustomCardHeader, } from "@/components"; -import { API_URL } from "@/config"; +import { API_URL, ROLE_IDS } from "@/config"; +import { User } from "@/interfaces"; -interface User { - full_name: string; - id: number; -} - -const ALL_TECHNICIANS_ID = -1; - -const allTechniciansOption: User = { - id: ALL_TECHNICIANS_ID, - full_name: "All Technicians", +type FormValues = { + from: Dayjs; + to: Dayjs; + techicians: User[]; // keep your existing form name + trss: string; }; const schema = yup.object().shape({ @@ -72,12 +69,20 @@ const schema = yup.object().shape({ const defaultSchema = { from: dayjs().startOf("month"), to: dayjs().endOf("month"), - techicians: [{ ...allTechniciansOption }], + techicians: [], trss: "", }; +const isoToDayjs = (s?: string, fallback?: Dayjs) => + s ? dayjs(s, "YYYY-MM-DD") : (fallback ?? dayjs()); + export const MaintenanceReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); const authHeader = useAuthHeader(); + + const hydratedRef = useRef(false); + const techiciansQuery = useQuery({ queryKey: ["users"], queryFn: async () => { @@ -97,42 +102,119 @@ export const MaintenanceReportView = () => { refetchOnReconnect: false, }); + const technicianOptions = useMemo(() => { + return techiciansQuery.data ?? []; + }, [techiciansQuery.data]); + + // URL -> RHF default values (technicians are hydrated after users load) + const defaultValues = useMemo(() => { + const fallbackFrom = dayjs().startOf("month"); + const fallbackTo = dayjs().endOf("month"); + + return { + from: isoToDayjs(search.from, fallbackFrom), + to: isoToDayjs(search.to, fallbackTo), + techicians: [], // hydrate from search.technicians once we have users + trss: search.trss ?? "", + }; + }, [search.from, search.to, search.trss]); + const { control, reset, setValue, watch } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, }); + // keep form in sync if URL changes (back/forward) + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + // hydrate selected techs from URL ids AFTER users load + useEffect(() => { + const users = techiciansQuery.data; + if (!users) return; + + const ids = new Set(search.technicians ?? []); + let selected = users.filter((u: User) => ids.has(u.id)); + + // if no ids provided, default to "all techs" + if (selected.length === 0) + selected = users?.filter( + (u: User) => u?.user_role_id === ROLE_IDS.TECHNICIAN, + ); + + setValue("techicians", selected, { + shouldDirty: false, + shouldValidate: true, + }); + + hydratedRef.current = true; + }, [techiciansQuery.data, search.technicians, setValue]); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/maintenance", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + const from = watch("from"); const to = watch("to"); const technicians = watch("techicians"); const trss = watch("trss"); - const technicianOptions = useMemo(() => { - const base = techiciansQuery.data ?? []; - return [...base, allTechniciansOption]; - }, [techiciansQuery.data]); + // push form -> URL (but only after hydration so we don't wipe URL on refresh) + useEffect(() => { + if (!hydratedRef.current) return; + + setSearch((prev) => ({ + ...(prev as any), + from: from?.format("YYYY-MM-DD"), + to: to?.format("YYYY-MM-DD"), + trss: trss?.trim() || undefined, + technicians: (technicians ?? []).map((t) => t.id), + page: 0, // reset paging on filter changes + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + from?.valueOf(), + to?.valueOf(), + trss, + (technicians ?? []).map((t) => t.id).join(","), + ]); const dataQuery = useQuery({ queryKey: [ "maintenance", { - from: from?.format("YYYY-MM-DD"), - to: to?.format("YYYY-MM-DD"), - trss: trss ?? "", - technicians: technicians?.map((t) => t.id) ?? [], + from: search.from, + to: search.to, + trss: search.trss ?? "", + technicians: search.technicians ?? [], + offset: search.page * search.pageSize, + limit: search.pageSize, }, ], queryFn: async () => { const queryParams = new URLSearchParams(); - queryParams.set("from_date", from?.format("YYYY-MM-DD")); - queryParams.set("to_date", to?.format("YYYY-MM-DD")); - queryParams.set("trss", trss ?? ""); + queryParams.set( + "from_date", + search.from ?? dayjs().startOf("month").format("YYYY-MM-DD"), + ); + queryParams.set( + "to_date", + search.to ?? dayjs().endOf("month").format("YYYY-MM-DD"), + ); + queryParams.set("trss", search.trss ?? ""); + + (search.technicians ?? []).forEach((id) => + queryParams.append("technicians", id.toString()), + ); - technicians - ?.map((t) => t.id) - .forEach((id) => { - queryParams.append("technicians", id.toString()); - }); + // if your API supports pagination: + queryParams.set("offset", String(search.page * search.pageSize)); + queryParams.set("limit", String(search.pageSize)); const response = await fetch( `${API_URL}/maintenance?${queryParams.toString()}`, @@ -140,16 +222,13 @@ export const MaintenanceReportView = () => { headers: { Authorization: authHeader() }, }, ); - - if (!response.ok) { - throw new Error("Failed to fetch maintenance data"); - } - + if (!response.ok) throw new Error("Failed to fetch maintenance data"); return response.json(); }, - staleTime: 1000 * 60 * 60 * 24, - cacheTime: 1000 * 60 * 60 * 24, - enabled: Boolean(from && to && technicians && technicians.length > 0), + enabled: Boolean( + search.from && search.to && (search.technicians?.length ?? 0) > 0, + ), + keepPreviousData: true, }); const numberOfRepairsPieChartData = useMemo(() => { @@ -363,16 +442,17 @@ export const MaintenanceReportView = () => { option?.id === value?.id } onChange={(_: React.SyntheticEvent, selected: User[]) => { - const isSelectingAll = selected.some( - (tech) => tech.id === ALL_TECHNICIANS_ID, - ); - const allTechs = techiciansQuery.data ?? []; + setValue("techicians", selected, { + shouldDirty: true, + shouldValidate: true, + }); - if (isSelectingAll) { - // Set all real users as selected, excluding the synthetic "All Technicians" - setValue("techicians", allTechs); - } else { - setValue("techicians", selected); + if (hydratedRef.current) { + setSearch((prev) => ({ + ...(prev as any), + technicians: selected.map((t) => t.id), + page: 0, + })); } }} renderInput={(params: Parameters[0]) => { @@ -488,18 +568,56 @@ export const MaintenanceReportView = () => { { + navigate({ + to: "/reports/maintenance", + search: (prev) => ({ + ...(prev as any), + pageSize: m.pageSize, + page: m.pageSize !== (prev as any).pageSize ? 0 : m.page, + }), + replace: true, + }); }} + rowCount={dataQuery.data?.total ?? tableRows.length} />
- +
From b35ee64828981f5fa99aeaf09edcbf6c34641ead Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Mar 2026 18:46:20 -0600 Subject: [PATCH 44/91] feat(reports/chlorides): Add URL state management --- frontend/src/routes/reports/chlorides.tsx | 12 +++ .../src/views/Reports/Chlorides/index.tsx | 76 +++++++++++++++++-- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/reports/chlorides.tsx b/frontend/src/routes/reports/chlorides.tsx index bed13671..51c41d1e 100644 --- a/frontend/src/routes/reports/chlorides.tsx +++ b/frontend/src/routes/reports/chlorides.tsx @@ -1,8 +1,20 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { ChloridesReportView } from "@/views/Reports/Chlorides"; import { ProtectedRoute } from "@/ProtectedRoute"; +const isoDate = z.preprocess((val) => { + const raw = Array.isArray(val) ? val[0] : val; + if (raw == null || raw === "") return undefined; + const s = String(raw).trim(); + return /^\d{4}-\d{2}-\d{2}$/.test(s) ? s : undefined; +}, z.string().optional()); + export const Route = createFileRoute("/reports/chlorides")({ + validateSearch: z.object({ + from: isoDate.catch(undefined), + to: isoDate.catch(undefined), + }), component: () => ( diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index bc2d00b1..e323ea43 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; @@ -23,12 +23,12 @@ import { Marker, Tooltip as MapTooltip, } from "react-leaflet"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import L from "leaflet"; - +import { Route } from "@/routes/reports/chlorides"; import { API_URL } from "@/config"; import { ControlledDatepicker, @@ -51,6 +51,11 @@ import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "leaflet/dist/leaflet.css"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; +type FormValues = { + from: Dayjs; + to: Dayjs; +}; + const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), to: yup @@ -83,15 +88,56 @@ interface iChlorideReportNums { west: iMinMaxAvgMedCount; } +const isoToDayjs = (s?: string, fallback?: Dayjs) => + s ? dayjs(s, "YYYY-MM-DD") : (fallback ?? dayjs()); + export const ChloridesReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + + const defaultValues = useMemo(() => { + const fallbackFrom = dayjs().startOf("month"); + const fallbackTo = dayjs().endOf("month"); + + return { + from: isoToDayjs(search.from, fallbackFrom), + to: isoToDayjs(search.to, fallbackTo), + }; + }, [search.from, search.to]); + const { control, reset, watch } = useForm({ resolver: yupResolver(schema), - defaultValues: defaultSchema, + defaultValues, }); + // If user hits back/forward or URL is edited, keep form in sync + useEffect(() => { + reset(defaultValues, { keepDirty: false, keepTouched: false }); + }, [defaultValues, reset]); + const from = watch("from"); const to = watch("to"); + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/chlorides", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + // form -> URL + useEffect(() => { + if (!from || !to) return; + + setSearch((prev) => ({ + ...(prev as any), + from: from.format("YYYY-MM-DD"), + to: to.format("YYYY-MM-DD"), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [from?.valueOf(), to?.valueOf()]); + const authHeader = useAuthHeader(); const fetchWithAuth = useFetchWithAuth(); @@ -444,7 +490,27 @@ export const ChloridesReportView = () => {
- + From 91933ee05ed184b49c9ed42aa7f482e701cc236e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Mar 2026 18:51:26 -0600 Subject: [PATCH 45/91] fix(reports/chlorides): rm unused constant --- frontend/src/views/Reports/Chlorides/index.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index e323ea43..5dc53738 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -68,11 +68,6 @@ const schema = yup.object().shape({ }), }); -const defaultSchema = { - from: dayjs().startOf("month"), - to: dayjs().endOf("month"), -}; - interface iMinMaxAvgMedCount { min?: number; max?: number; From 1e93977d838945d19339d107ea601aa97c18eb9c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Mar 2026 19:17:10 -0600 Subject: [PATCH 46/91] feat(Reports/Maintenance): Add group by in role --- .../src/views/Reports/Maintenance/index.tsx | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 848c81ed..13d34b8a 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -102,8 +102,16 @@ export const MaintenanceReportView = () => { refetchOnReconnect: false, }); - const technicianOptions = useMemo(() => { - return techiciansQuery.data ?? []; + const technicianOptions = useMemo(() => { + const users = (techiciansQuery.data ?? []) as User[]; + + return [...users].sort((a, b) => { + const ra = roleOrder[getRoleLabel(a)]; + const rb = roleOrder[getRoleLabel(b)]; + if (ra !== rb) return ra - rb; + + return (a.full_name ?? "").localeCompare(b.full_name ?? ""); + }); }, [techiciansQuery.data]); // URL -> RHF default values (technicians are hydrated after users load) @@ -455,6 +463,7 @@ export const MaintenanceReportView = () => { })); } }} + groupBy={(option: User) => getRoleLabel(option)} renderInput={(params: Parameters[0]) => { if (techiciansQuery.isLoading && params.inputProps) { params.inputProps.value = "Loading..."; @@ -624,3 +633,25 @@ export const MaintenanceReportView = () => { ); }; + +type RoleLabel = "Admin" | "Technician" | "OSE" | "Unknown"; + +const getRoleLabel = (u: User): RoleLabel => { + switch (u.user_role_id) { + case ROLE_IDS.ADMIN: + return "Admin"; + case ROLE_IDS.TECHNICIAN: + return "Technician"; + case ROLE_IDS.OSE: + return "OSE"; + default: + return "Unknown"; + } +}; + +const roleOrder: Record = { + Admin: 2, + Technician: 1, + OSE: 3, + Unknown: 99, +}; From e079ff586ef4ab9e19718544a14a635dd1b3e87f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Mar 2026 22:42:43 -0600 Subject: [PATCH 47/91] feat(manage/user): Add url state control to pg --- api/routes/admin.py | 27 +++++ frontend/src/components/TristateToggle.tsx | 61 ++++++---- frontend/src/routes/manage/users.tsx | 14 +++ frontend/src/service/ApiServiceNew.ts | 13 +++ frontend/src/views/Parts/PartsTable.tsx | 12 +- .../views/UserManagement/PermissionsTable.tsx | 93 ++-------------- .../views/UserManagement/RoleDetailsCard.tsx | 29 +++-- .../src/views/UserManagement/RolesTable.tsx | 64 ++++++----- .../views/UserManagement/UserDetailsCard.tsx | 24 ++-- .../UserManagement/UserManagementView.tsx | 70 +++++++----- .../src/views/UserManagement/UsersTable.tsx | 104 ++++++++++-------- 11 files changed, 287 insertions(+), 224 deletions(-) diff --git a/api/routes/admin.py b/api/routes/admin.py index b40e0763..71bd8703 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -112,6 +112,33 @@ def create_user(user: security_schemas.NewUser, db: Session = Depends(get_db)): return qualified_user +@admin_router.get( + "/users/{id}", + response_model=security_schemas.User, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Admin"], +) +def get_user_admin(id: int, db: Session = Depends(get_db)): + """ + Admin-specific single user endpoint (includes username/email/role) + """ + user = db.scalars( + select(Users) + .options( + undefer(Users.username), + undefer(Users.user_role_id), + undefer(Users.email), + joinedload(Users.user_role), + ) + .where(Users.id == id) + ).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return user + + @admin_router.get( "/usersadmin", response_model=List[security_schemas.User], diff --git a/frontend/src/components/TristateToggle.tsx b/frontend/src/components/TristateToggle.tsx index d3032cae..880f36b3 100644 --- a/frontend/src/components/TristateToggle.tsx +++ b/frontend/src/components/TristateToggle.tsx @@ -1,44 +1,57 @@ -import { Chip } from "@mui/material"; -import { useEffect, useState } from "react"; +import { Chip, type ChipProps } from "@mui/material"; -export const TristateToggle = ({ label, onToggle }: any) => { - const [toggleState, setToggleState] = useState(); +export type TriString = "all" | "true" | "false"; - useEffect(() => { - onToggle(toggleState); - }, [toggleState]); - - function getColor() { - switch (toggleState) { - case true: +export const TristateToggle = ({ + label, + value, + onToggle, +}: { + label: string; + value: TriString; + onToggle: (value: TriString) => void; +}) => { + const getColor = (): ChipProps["color"] | undefined => { + switch (value) { + case "true": return "success"; - case false: + case "false": return "error"; default: return undefined; } - } + }; - function getLabel() { - switch (toggleState) { - case true: - return "Is " + label; - case false: - return "Is Not " + label; + const getLabel = () => { + switch (value) { + case "true": + return `Is ${label}`; + case "false": + return `Is Not ${label}`; default: return label; } - } + }; + + const nextValue = (v: TriString): TriString => { + switch (v) { + case "all": + return "true"; + case "true": + return "false"; + case "false": + return "all"; + } + }; return ( setToggleState(undefined) : undefined - } - onClick={() => setToggleState(!toggleState)} + variant={value === "all" ? "outlined" : "filled"} + onDelete={value === "all" ? undefined : () => onToggle("all")} + onClick={() => onToggle(nextValue(value))} /> ); }; diff --git a/frontend/src/routes/manage/users.tsx b/frontend/src/routes/manage/users.tsx index 0a0d437f..13b9567d 100644 --- a/frontend/src/routes/manage/users.tsx +++ b/frontend/src/routes/manage/users.tsx @@ -1,8 +1,22 @@ import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; import { UserManagementView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; +const tri = z.enum(["all", "true", "false"]).catch("all"); + export const Route = createFileRoute("/manage/users")({ + validateSearch: z.object({ + user_id: z.number().optional(), + user_add: z.boolean().catch(true).default(true), + user_q: z.string().optional().default(""), + active: tri.default("true"), + tech: tri.default("all"), + + role_id: z.number().optional(), + role_add: z.boolean().catch(true).default(true), + role_q: z.string().optional().default(""), + }), component: () => ( diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 2b50cb4f..ae6a3d89 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -366,6 +366,19 @@ export function useGetUserList() { ); } +export function useGetUser(id: number, options = {}) { + const route = "users"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery( + [route], + () => GETFetch(`${route}/${id}`, null, authHeader(), signOut, navigate), + options, + ); +} + export function useGetActivityTypeList() { const route = "activity_types"; const authHeader = useAuthHeader(); diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index cd1a2567..ae7517d8 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -29,6 +29,7 @@ import { IncreaseQuantityModal, IsTrueChip, TristateToggle, + TriString, } from "@/components"; export const PartsTable = ({ @@ -167,7 +168,16 @@ export const PartsTable = ({ setInUseFilter(state)} + value="all" + onToggle={(state: TriString) => + setInUseFilter( + state === "true" + ? true + : state === "false" + ? false + : undefined, + ) + } /> { const securityScopesList = useGetSecurityScopes(); - const [permissionSearchQuery, setPermissionSearchQuery] = - useState(""); - const [filteredRows, setFilteredRows] = useState(); const cols: GridColDef[] = [ - { field: "scope_string", headerName: "Permission Name", width: 200 }, - { field: "description", headerName: "Desciption", width: 600 }, + { + field: "scope_string", + headerName: "Permission Name", + flex: 1, + }, + { field: "description", headerName: "Desciption", flex: 3 }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid - useEffect(() => { - const psq = permissionSearchQuery.toLowerCase(); - let filtered = (securityScopesList.data ?? []).filter( - (row) => - row.scope_string.toLowerCase().includes(psq) || - row.description.toLowerCase().includes(psq), - ); - - setFilteredRows(filtered); - }, [permissionSearchQuery, securityScopesList.data]); - return ( { /> - - - setPermissionSearchQuery(event.target.value) - } - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - - - - - ), - }, - }} disableColumnFilter + hideFooter /> diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 7aa784ad..958cff13 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -20,7 +20,12 @@ import { yupResolver } from "@hookform/resolvers/yup"; import { enqueueSnackbar } from "notistack"; import { useFieldArray } from "react-hook-form"; -import { useCreateRole, useGetSecurityScopes, useUpdateRole } from "@/service"; +import { + useCreateRole, + useGetRoles, + useGetSecurityScopes, + useUpdateRole, +} from "@/service"; import { ControlledTextbox, CustomCardHeader } from "@/components"; import { SecurityScope, UserRole } from "@/interfaces"; @@ -29,12 +34,12 @@ const RoleResolverSchema: Yup.ObjectSchema = Yup.object().shape({ }); interface RoleDetailsCardProps { - selectedRole: UserRole | undefined; + roleId?: number; roleAddMode: boolean; } export const RoleDetailsCard = ({ - selectedRole, + roleId, roleAddMode, }: RoleDetailsCardProps) => { const { @@ -54,6 +59,9 @@ export const RoleDetailsCard = ({ }); const securityScopeList = useGetSecurityScopes(); + const rolesList = useGetRoles(); + + const selectedRole = rolesList.data?.find((role) => role.id === roleId); function onSuccessfulUpdate() { enqueueSnackbar("Successfully Updated Role!", { variant: "success" }); @@ -71,18 +79,23 @@ export const RoleDetailsCard = ({ // Populate the form with the selected role's details useEffect(() => { + if (roleAddMode) { + reset(); + return; + } + if (selectedRole != undefined) { reset(); Object.entries(selectedRole).forEach(([field, value]) => { setValue(field as any, value); }); + return; } - }, [selectedRole]); - // Empty the form if entering role add mode - useEffect(() => { - if (roleAddMode) reset(); - }, [roleAddMode]); + if (roleId == undefined) { + reset(); + } + }, [roleAddMode, roleId, reset, selectedRole, setValue]); function removeSecurityScope(securityScopeIndex: number) { remove(securityScopeIndex); diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 240f2f7c..b5006cc6 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useMemo } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, @@ -10,27 +10,44 @@ import { TextField, } from "@mui/material"; import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { useNavigate } from "@tanstack/react-router"; import { useGetRoles } from "@/service"; -import { UserRole } from "@/interfaces"; +import { Route } from "@/routes/manage/users"; import { CustomCardHeader, GridFooterWithButton } from "@/components"; export const RolesTable = ({ - setSelectedRole, - setRoleAddMode, + onSelectRole, + onCreateRole, }: { - setSelectedRole: Function; - setRoleAddMode: Function; + onSelectRole: (id: number) => void; + onCreateRole: () => void; }) => { const rolesList = useGetRoles(); - const [roleSearchQuery, setRoleSearchQuery] = useState(""); - const [filteredRows, setFilteredRows] = useState(); + const navigate = useNavigate(); + const search = Route.useSearch(); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/manage/users", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + const filteredRows = useMemo(() => { + const q = (search.role_q ?? "").toLowerCase(); + + return (rolesList.data ?? []).filter((row) => + row.name.toLowerCase().includes(q), + ); + }, [rolesList.data, search.role_q]); const cols: GridColDef[] = [ - { field: "name", headerName: "Role Name", width: 200 }, + { field: "name", headerName: "Role Name", flex: 1 }, { field: "security_scopes", headerName: "Permissions", - width: 600, + flex: 3, renderCell: (params: any) => { const maxChips = 5; const additional = params?.value.length - maxChips; @@ -52,16 +69,6 @@ export const RolesTable = ({ }, ]; - // Filter rows based on search. Cant use multiple filters w/o pro datagrid - useEffect(() => { - const psq = roleSearchQuery.toLowerCase(); - let filtered = (rolesList.data ?? []).filter((row) => - row.name.toLowerCase().includes(psq), - ); - - setFilteredRows(filtered); - }, [roleSearchQuery, rolesList.data]); - return ( @@ -81,8 +88,10 @@ export const RolesTable = ({ placeholder="Search Roles..." variant="outlined" size="small" - value={roleSearchQuery} - onChange={(event: any) => setRoleSearchQuery(event.target.value)} + value={search.role_q ?? ""} + onChange={(event: any) => + setSearch((prev) => ({ ...prev, role_q: event.target.value })) + } InputProps={{ startAdornment: ( @@ -96,16 +105,11 @@ export const RolesTable = ({ { - setSelectedRole( - rolesList.data?.find( - (role: UserRole) => role.id == selectedRow.row.id, - ), - ); - }} + onRowClick={(selectedRow) => onSelectRole(selectedRow.row.id)} slots={{ footer: GridFooterWithButton }} slotProps={{ footer: { @@ -113,7 +117,7 @@ export const RolesTable = ({ + {hasChanges && }
diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index d1c12877..ffba4e6b 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useAuthHeader } from "react-auth-kit"; import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; import { @@ -13,7 +13,7 @@ import { TextField, Tooltip, } from "@mui/material"; -import { Link } from "@tanstack/react-router"; +import { Link, useNavigate } from "@tanstack/react-router"; import { Controller, useForm } from "react-hook-form"; import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; @@ -28,6 +28,7 @@ import { CustomCardHeader, ControlledSelect, } from "@/components"; +import { Route } from "@/routes/reports/partsused"; export interface MeterType { id: number; @@ -102,17 +103,45 @@ const defaultSchema = { }; export const PartsUsedReportView = () => { - const { control, reset, watch } = useForm({ + const navigate = useNavigate(); + const search = Route.useSearch(); + const hydratedRef = useRef(false); + + const defaultValues = useMemo( + () => ({ + from: dayjs(search.from, "YYYY-MM-DD"), + to: dayjs(search.to, "YYYY-MM-DD"), + part_types: [], + parts: [], + in_use: search.in_use, + }), + [search.from, search.to, search.in_use], + ); + + const { control, reset, watch, setValue } = useForm({ resolver: yupResolver(schema), - defaultValues: defaultSchema, + defaultValues, }); + useEffect(() => { + hydratedRef.current = false; + reset(defaultValues); + }, [defaultValues, reset]); + const from = watch("from"); const to = watch("to"); const selectedPartIds = watch("parts") ?? []; const partTypes = watch("part_types"); const inUse = watch("in_use"); + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/partsused", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + const authHeader = useAuthHeader(); const partsQuery = useQuery({ queryKey: ["Inventory", "report", "partslist", inUse], @@ -129,6 +158,57 @@ export const PartsUsedReportView = () => { cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours }); + const partTypeOptions = useMemo( + () => [ + ...new Map( + (partsQuery?.data ?? []) + .map((option: Part) => ({ + id: option.part_type_id, + type: option.part_type, + })) + .map((item) => [item.id, item]), + ).values(), + ], + [partsQuery.data], + ); + + useEffect(() => { + setValue("from", dayjs(search.from, "YYYY-MM-DD"), { + shouldDirty: false, + shouldValidate: true, + }); + setValue("to", dayjs(search.to, "YYYY-MM-DD"), { + shouldDirty: false, + shouldValidate: true, + }); + setValue("in_use", search.in_use, { + shouldDirty: false, + shouldValidate: true, + }); + }, [search.from, search.to, search.in_use, setValue]); + + useEffect(() => { + if (!partsQuery.data) return; + + const selected = partTypeOptions.filter((option) => + search.part_types.includes(option.id), + ); + setValue("part_types", selected, { + shouldDirty: false, + shouldValidate: true, + }); + + const availablePartIds = new Set(partsQuery.data.map((part) => part.id)); + const selectedParts = search.parts.filter((id) => availablePartIds.has(id)); + + setValue("parts", selectedParts, { + shouldDirty: false, + shouldValidate: true, + }); + + hydratedRef.current = true; + }, [partTypeOptions, partsQuery.data, search.part_types, search.parts, setValue]); + const filteredParts = useMemo(() => { if (!partsQuery.data) return []; @@ -143,15 +223,57 @@ export const PartsUsedReportView = () => { }, [partsQuery.data, partTypes]); useEffect(() => { - const currentParts = watch("parts") ?? []; + if (!hydratedRef.current) return; + const validIds = filteredParts.map((p) => p.id); - const stillValid = currentParts.filter((id) => validIds.includes(id)); + const stillValid = selectedPartIds.filter((id) => validIds.includes(id)); - if (currentParts.length !== stillValid.length) { - // Drop invalid part IDs - reset({ ...watch(), parts: stillValid }); + if (selectedPartIds.length !== stillValid.length) { + setValue("parts", stillValid, { + shouldDirty: false, + shouldValidate: true, + }); + setSearch((prev) => ({ + ...prev, + parts: stillValid, + page: 0, + })); } - }, [partTypes, filteredParts]); + }, [filteredParts, selectedPartIds, setSearch, setValue]); + + useEffect(() => { + if (!hydratedRef.current) return; + + const nextFrom = from?.format("YYYY-MM-DD"); + const nextTo = to?.format("YYYY-MM-DD"); + const nextPartTypes = (partTypes ?? []).map((partType: any) => partType.id); + + setSearch((prev) => { + const sameFrom = prev.from === nextFrom; + const sameTo = prev.to === nextTo; + const sameInUse = prev.in_use === inUse; + const samePartTypes = + prev.part_types.length === nextPartTypes.length && + prev.part_types.every((value, index) => value === nextPartTypes[index]); + const sameParts = + prev.parts.length === selectedPartIds.length && + prev.parts.every((value, index) => value === selectedPartIds[index]); + + if (sameFrom && sameTo && sameInUse && samePartTypes && sameParts) { + return prev; + } + + return { + ...prev, + from: nextFrom, + to: nextTo, + part_types: nextPartTypes, + parts: selectedPartIds, + in_use: inUse, + page: 0, + }; + }); + }, [from, to, partTypes, selectedPartIds, inUse]); const partsUsedQuery = useQuery({ queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], @@ -345,16 +467,7 @@ export const PartsUsedReportView = () => { name="part_types" multiple disabled={partsQuery.isFetching} - options={[ - ...new Map( - partsQuery?.data - ?.map((option: Part) => ({ - id: option.part_type_id, - type: option.part_type, - })) - .map((item) => [item.id, item]), // key by id - ).values(), - ]} + options={partTypeOptions} getOptionLabel={(option: any) => option.type.name} /> @@ -429,16 +542,36 @@ export const PartsUsedReportView = () => { columns={columns} disableColumnMenu hideFooterSelectedRowCount + pagination pageSizeOptions={[5, 10, 25]} - initialState={{ - pagination: { - paginationModel: { pageSize: 5, page: 0 }, - }, - }} + paginationModel={{ page: search.page, pageSize: search.pageSize }} + onPaginationModelChange={(model) => + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } /> - + From ce02716b12e93cf6d37b8c06a9f30979eb0993a6 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Mar 2026 23:37:45 -0600 Subject: [PATCH 50/91] feat(settings): Add avatar & password reset --- api/routes/settings.py | 147 ++++++++++++++++- frontend/src/hooks/useFetchWithAuth.ts | 9 +- frontend/src/interfaces/User.ts | 2 + frontend/src/views/Login.tsx | 23 ++- frontend/src/views/Settings.tsx | 215 ++++++++++++++++++++++--- 5 files changed, 369 insertions(+), 27 deletions(-) diff --git a/api/routes/settings.py b/api/routes/settings.py index 4e94ae38..90c51e15 100644 --- a/api/routes/settings.py +++ b/api/routes/settings.py @@ -1,12 +1,25 @@ -from fastapi import Depends, APIRouter, HTTPException +from base64 import b64encode +from io import BytesIO + +from fastapi import Depends, APIRouter, HTTPException, File, UploadFile +from PIL import Image, UnidentifiedImageError +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from api.schemas.base import ORMBase from api.session import get_db -from api.security import get_current_user +from api.security import get_current_user, get_password_hash, verify_password from api.models.main_models import Users settings_router = APIRouter() +MAX_AVATAR_FILE_SIZE_BYTES = 5 * 1024 * 1024 +MAX_AVATAR_PIXELS = 4096 * 4096 +ALLOWED_AVATAR_FORMATS = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "WEBP": "image/webp", + "GIF": "image/gif", +} @settings_router.get( @@ -52,6 +65,11 @@ class DisplayNameUpdate(ORMBase): display_name: str +class PasswordResetRequest(ORMBase): + current_password: str + new_password: str + + @settings_router.post( "/settings/display_name", tags=["settings"], @@ -70,3 +88,128 @@ def post_redirect_page( db.refresh(db_user) return {"message": "Display name updated", "display_name": db_user.display_name} + + +@settings_router.post( + "/settings/password_reset", + tags=["settings"], +) +def post_password_reset( + update: PasswordResetRequest, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + if not verify_password(update.current_password, db_user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + + if update.current_password == update.new_password: + raise HTTPException( + status_code=400, + detail="New password must be different from current password", + ) + + if len(update.new_password) < 8: + raise HTTPException( + status_code=400, + detail="New password must be at least 8 characters long", + ) + + db_user.hashed_password = get_password_hash(update.new_password) + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update password") + + return {"message": "Password updated"} + + +@settings_router.post( + "/settings/avatar", + tags=["settings"], +) +async def post_avatar( + avatar: UploadFile = File(...), + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + avatar_bytes = await avatar.read() + + if not avatar_bytes: + raise HTTPException(status_code=400, detail="Avatar image is required") + + if len(avatar_bytes) > MAX_AVATAR_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"Avatar image exceeds {MAX_AVATAR_FILE_SIZE_BYTES // (1024 * 1024)} MB limit", + ) + + try: + with Image.open(BytesIO(avatar_bytes)) as image: + width, height = image.size + if width * height > MAX_AVATAR_PIXELS: + raise HTTPException(status_code=400, detail="Avatar image is too large") + + image.verify() + + with Image.open(BytesIO(avatar_bytes)) as image: + image_format = image.format + + except HTTPException: + raise + except (UnidentifiedImageError, Image.DecompressionBombError, OSError): + raise HTTPException(status_code=400, detail="Uploaded file is not a valid image") + + if image_format not in ALLOWED_AVATAR_FORMATS: + raise HTTPException( + status_code=400, + detail="Avatar image must be a JPEG, PNG, WEBP, or GIF file", + ) + + db_user.avatar_img = ( + f"data:{ALLOWED_AVATAR_FORMATS[image_format]};base64," + f"{b64encode(avatar_bytes).decode('ascii')}" + ) + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to update avatar image") + + return {"message": "Avatar updated", "avatar_img": db_user.avatar_img} + + +@settings_router.delete( + "/settings/avatar", + tags=["settings"], +) +def delete_avatar( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + db_user = db.query(Users).filter(Users.id == user.id).first() + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + db_user.avatar_img = None + + try: + db.commit() + db.refresh(db_user) + except SQLAlchemyError: + db.rollback() + raise HTTPException(status_code=500, detail="Failed to clear avatar image") + + return {"message": "Avatar cleared", "avatar_img": db_user.avatar_img} diff --git a/frontend/src/hooks/useFetchWithAuth.ts b/frontend/src/hooks/useFetchWithAuth.ts index 0142681d..7fb702bc 100644 --- a/frontend/src/hooks/useFetchWithAuth.ts +++ b/frontend/src/hooks/useFetchWithAuth.ts @@ -24,19 +24,24 @@ export const useFetchWithAuth = () => { responseType?: "json" | "blob" | "text" | "response"; }) => { const url = `${API_URL}${route}${formatQueryParams(params)}`; + const isFormData = body instanceof FormData; const response = await fetch(url, { method, headers: { Authorization: authHeader(), // Only set JSON content-type when sending JSON - ...(body && ["PATCH", "POST", "PUT", "DELETE"].includes(method) + ...(body && + !isFormData && + ["PATCH", "POST", "PUT", "DELETE"].includes(method) ? { "Content-Type": "application/json" } : {}), }, body: body && ["PATCH", "POST", "PUT", "DELETE"].includes(method) - ? JSON.stringify(body) + ? isFormData + ? body + : JSON.stringify(body) : undefined, }); diff --git a/frontend/src/interfaces/User.ts b/frontend/src/interfaces/User.ts index 1e3e600f..de14c955 100644 --- a/frontend/src/interfaces/User.ts +++ b/frontend/src/interfaces/User.ts @@ -10,5 +10,7 @@ export interface User { disabled: boolean; user_role_id?: number; user_role?: UserRole; + redirect_page?: string; + avatar_img?: string | null; password?: string; } diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index 4729efda..8fc0bc3d 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -10,8 +10,14 @@ import { Alert, Stack, Grid, + InputAdornment, + IconButton, } from "@mui/material"; -import { Login as LoginIcon } from "@mui/icons-material"; +import { + Login as LoginIcon, + Visibility, + VisibilityOff, +} from "@mui/icons-material"; import { enqueueSnackbar } from "notistack"; import { SecurityScope } from "@/interfaces"; import { API_URL } from "@/config"; @@ -21,6 +27,7 @@ export const Login = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [showPassword, setShowPassword] = useState(false); const signIn = useSignIn(); const isAuthenticated = useIsAuthenticated(); @@ -127,9 +134,21 @@ export const Login = () => { required fullWidth label="Password" - type="password" + type={showPassword ? "text" : "password"} name="password" onChange={(e) => setPassword(e.target.value)} + InputProps={{ + endAdornment: ( + + setShowPassword((show) => !show)} + > + {showPassword ? : } + + + ), + }} /> diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index a6cdf344..c8c27ad6 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -22,10 +22,12 @@ import { Skeleton, IconButton, Stack, + InputAdornment, } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; import { useAuthUser, useSignIn } from "react-auth-kit"; import { Check, Close, Delete, Edit, ExpandMore } from "@mui/icons-material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { BackgroundBox, @@ -50,7 +52,10 @@ const redirectSchema = yup.object().shape({ const passwordSchema = yup.object().shape({ currentPassword: yup.string().required("Current password is required"), - newPassword: yup.string().required("New password is required"), + newPassword: yup + .string() + .min(8, "New password must be at least 8 characters") + .required("New password is required"), confirmPassword: yup .string() .oneOf([yup.ref("newPassword")], "Passwords must match") @@ -72,6 +77,11 @@ export const Settings = () => { const hasAdminScope = scopes.has("admin"); const [isEditing, setIsEditing] = useState(false); + const [avatarFiles, setAvatarFiles] = useState([]); + const [avatarUploadKey, setAvatarUploadKey] = useState(0); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); const { control: displayNameControl, @@ -185,27 +195,39 @@ export const Settings = () => { currentPassword: string; newPassword: string; }) => { - const res = await fetch("/settings/password_reset", { + return await fetchWithAuth({ method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${localStorage.getItem("_auth")}`, + route: "/settings/password_reset", + body: { + current_password: data.currentPassword, + new_password: data.newPassword, }, - body: JSON.stringify(data), }); - if (!res.ok) throw new Error("Password reset failed"); - return await res.json(); }, onSuccess: () => { - enqueueSnackbar("Password reset request submitted.", { + enqueueSnackbar("Password updated successfully.", { variant: "success", }); + passwordReset({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + setShowCurrentPassword(false); + setShowNewPassword(false); + setShowConfirmPassword(false); + }, + onError: (error: Error) => { + enqueueSnackbar(error.message || "Failed to update password.", { + variant: "error", + }); }, }); const { control: passwordControl, handleSubmit: handlePasswordSubmit, + reset: passwordReset, formState: { errors: passwordErrors }, } = useForm({ resolver: yupResolver(passwordSchema), @@ -223,6 +245,84 @@ export const Settings = () => { }); }; + const avatarMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("avatar", file); + + return await fetchWithAuth({ + method: "POST", + route: "/settings/avatar", + body: formData, + }); + }, + onSuccess: (responseJson: { avatar_img: string }) => { + enqueueSnackbar("Avatar updated successfully.", { + variant: "success", + }); + setAvatarFiles([]); + setAvatarUploadKey((current) => current + 1); + + if (user) { + signIn({ + token: localStorage.getItem("_auth")!, + expiresIn: 300, + tokenType: "bearer", + authState: { + ...user, + avatar_img: responseJson.avatar_img, + }, + }); + } + }, + onError: () => { + enqueueSnackbar("Failed to update avatar.", { variant: "error" }); + }, + }); + + const clearAvatarMutation = useMutation({ + mutationFn: async () => { + return await fetchWithAuth({ + method: "DELETE", + route: "/settings/avatar", + }); + }, + onSuccess: () => { + enqueueSnackbar("Avatar removed successfully.", { + variant: "success", + }); + setAvatarFiles([]); + setAvatarUploadKey((current) => current + 1); + + if (user) { + signIn({ + token: localStorage.getItem("_auth")!, + expiresIn: 300, + tokenType: "bearer", + authState: { + ...user, + avatar_img: null, + }, + }); + } + }, + onError: () => { + enqueueSnackbar("Failed to remove avatar.", { variant: "error" }); + }, + }); + + const onAvatarSubmit = () => { + const file = avatarFiles[0]; + if (!file) { + enqueueSnackbar("Select an image before saving your avatar.", { + variant: "warning", + }); + return; + } + + avatarMutation.mutate(file); + }; + return ( @@ -364,7 +464,7 @@ export const Settings = () => { - + }> Avatar Configuration @@ -376,19 +476,35 @@ export const Settings = () => { justifyContent="center" > - + - + + @@ -584,7 +700,7 @@ export const Settings = () => { - + }> Password Reset @@ -600,14 +716,33 @@ export const Settings = () => { render={({ field }) => ( + + setShowCurrentPassword((show) => !show) + } + > + {showCurrentPassword ? ( + + ) : ( + + )} + + + ), + }} /> )} /> @@ -619,14 +754,33 @@ export const Settings = () => { render={({ field }) => ( + + setShowNewPassword((show) => !show) + } + > + {showNewPassword ? ( + + ) : ( + + )} + + + ), + }} /> )} /> @@ -638,14 +792,33 @@ export const Settings = () => { render={({ field }) => ( + + setShowConfirmPassword((show) => !show) + } + > + {showConfirmPassword ? ( + + ) : ( + + )} + + + ), + }} /> )} /> From 67476571a8d0388e1ee03026376c066737ef37cc Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 00:07:49 -0600 Subject: [PATCH 51/91] feat(PartsHistory): Add ability to update count history --- api/routes/parts.py | 292 +++++++++++------- api/schemas/part_schemas.py | 12 + .../src/interfaces/PartHistoryResponse.ts | 15 +- frontend/src/service/ApiServiceNew.ts | 61 +++- frontend/src/views/Parts/PartsHistory.tsx | 216 ++++++++++--- 5 files changed, 446 insertions(+), 150 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 20d7b186..ca771877 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -13,11 +13,10 @@ PartAssociation, PartTypeLU, Meters, - MeterTypeLU, - meterRegisters, - MeterActivities, - workOrders, -) + MeterTypeLU, + meterRegisters, + MeterActivities, +) from api.schemas import part_schemas from api.session import get_db from api.route_util import _get @@ -28,12 +27,98 @@ TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" -templates = Environment( - loader=FileSystemLoader(TEMPLATES_DIR), - autoescape=select_autoescape(["html", "xml"]), -) - -part_router = APIRouter() +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]), +) + +part_router = APIRouter() + + +def _build_part_history_response(part_id: int, db: Session) -> part_schemas.PartHistoryResponse: + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + added_q = select( + PartsAdded.id.label("ref_id"), + PartsAdded.part_id.label("part_id"), + PartsAdded.date.label("event_date"), + literal("added").label("event_type"), + PartsAdded.note.label("note"), + PartsAdded.count.label("delta"), + literal(None).label("work_order_id"), + ).where(PartsAdded.part_id == part_id) + + used_q = ( + select( + PartsUsed.id.label("ref_id"), + PartsUsed.part_id.label("part_id"), + MeterActivities.timestamp_start.label("event_date"), + literal("used").label("event_type"), + func.nullif(func.trim(MeterActivities.description), "").label("note"), + (-PartsUsed.count).label("delta"), + MeterActivities.work_order_id.label("work_order_id"), + ) + .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) + .where(PartsUsed.part_id == part_id) + ) + + events = union_all(added_q, used_q).subquery() + + rows = db.execute( + select( + events.c.ref_id, + events.c.part_id, + events.c.event_date, + events.c.event_type, + events.c.note, + events.c.delta, + events.c.work_order_id, + ).order_by(events.c.event_date.asc(), events.c.ref_id.asc()) + ).all() + + running = int(part.initial_count) + history: list[part_schemas.PartHistoryRow] = [ + part_schemas.PartHistoryRow( + row_id=f"initial-{part_id}", + part_id=part_id, + event_date=datetime.min, + event_type="initial", + ref_id=None, + note="Initial count", + delta=0, + total_after=running, + work_order_id=None, + ) + ] + + for ref_id, pid, event_date, event_type, note, delta, work_order_id in rows: + if not isinstance(event_date, datetime): + event_date = datetime.combine(event_date, time.min) + + running += int(delta) + history.append( + part_schemas.PartHistoryRow( + row_id=f"{event_type}-{ref_id}", + part_id=pid, + event_date=event_date, + event_type=event_type, + ref_id=ref_id, + note=note, + delta=int(delta), + total_after=running, + work_order_id=work_order_id, + ) + ) + + return part_schemas.PartHistoryResponse( + part_id=part.id, + part_number=part.part_number, + initial_count=part.initial_count, + current_count=running, + history=history, + ) @part_router.get( @@ -456,103 +541,88 @@ def add_parts(payload: part_schemas.PartsAddRequest, db: Session = Depends(get_d dependencies=[Depends(ScopedUser.Admin)], tags=["Parts"], ) -def get_part_history(part_id: int, db: Session = Depends(get_db)): - part = db.scalars(select(Parts).where(Parts.id == part_id)).first() - if not part: - raise HTTPException(status_code=404, detail="Part not found") - - # ADDED events (date is a DATE) - added_q = select( - PartsAdded.id.label("ref_id"), - PartsAdded.part_id.label("part_id"), - PartsAdded.date.label("event_date"), # date - literal("added").label("event_type"), - PartsAdded.note.label("note"), - PartsAdded.count.label("delta"), - literal(None).label("work_order_id"), - ).where(PartsAdded.part_id == part_id) - - # USED events (datetime comes from MeterActivities.timestamp_start) - used_q = ( - select( - PartsUsed.id.label("ref_id"), - PartsUsed.part_id.label("part_id"), - MeterActivities.timestamp_start.label("event_date"), # datetime - literal("used").label("event_type"), - func.coalesce( - func.nullif(func.trim(MeterActivities.description), ""), - func.nullif(func.trim(workOrders.description), ""), - func.nullif(func.trim(workOrders.notes), ""), - func.nullif(func.trim(workOrders.title), ""), - ).label("note"), - (-PartsUsed.count).label("delta"), - MeterActivities.work_order_id.label("work_order_id"), - ) - .join(MeterActivities, MeterActivities.id == PartsUsed.meter_activity_id) - .outerjoin( - workOrders, - workOrders.id == MeterActivities.work_order_id, - ) - .where(PartsUsed.part_id == part_id) - ) - - events = union_all(added_q, used_q).subquery() - - rows = db.execute( - select( - events.c.ref_id, - events.c.part_id, - events.c.event_date, - events.c.event_type, - events.c.note, - events.c.delta, - events.c.work_order_id, - ).order_by(events.c.event_date.asc(), events.c.ref_id.asc()) - ).all() - - running = int(part.initial_count) - - history: list[part_schemas.PartHistoryRow] = [ - part_schemas.PartHistoryRow( - row_id=f"initial-{part_id}", - part_id=part_id, - event_date=datetime.min, - event_type="initial", - ref_id=None, - note="Initial count", - delta=0, - total_after=running, - work_order_id=None, - ) - ] - - for ref_id, pid, event_date, event_type, note, delta, work_order_id in rows: - # convert DATE -> DATETIME if needed - if not isinstance(event_date, datetime): - event_date = datetime.combine(event_date, time.min) - - running += int(delta) - - history.append( - part_schemas.PartHistoryRow( - row_id=f"{event_type}-{ref_id}", - part_id=pid, - event_date=event_date, - event_type=event_type, - ref_id=ref_id, - note=note, - delta=int(delta), - total_after=running, - work_order_id=work_order_id, - ) - ) - - current_count = running - - return part_schemas.PartHistoryResponse( - part_id=part.id, - part_number=part.part_number, - initial_count=part.initial_count, - current_count=current_count, - history=history, - ) +def get_part_history(part_id: int, db: Session = Depends(get_db)): + return _build_part_history_response(part_id, db) + + +@part_router.patch( + "/parts/{part_id}/history", + response_model=part_schemas.PartHistoryResponse, + dependencies=[Depends(ScopedUser.Admin)], + tags=["Parts"], +) +def patch_part_history( + part_id: int, + payload: part_schemas.PartHistoryUpdateRequest, + db: Session = Depends(get_db), +): + part = db.scalars(select(Parts).where(Parts.id == part_id)).first() + if not part: + raise HTTPException(status_code=404, detail="Part not found") + + for row in payload.rows: + normalized_note = row.note.strip() if row.note else None + if normalized_note == "": + normalized_note = None + + if row.event_type == "added": + if row.delta <= 0: + raise HTTPException( + status_code=422, + detail="Added parts rows must have a positive change.", + ) + + added_row = db.scalars( + select(PartsAdded).where( + PartsAdded.id == row.ref_id, + PartsAdded.part_id == part_id, + ) + ).first() + if not added_row: + raise HTTPException(status_code=404, detail="Parts added row not found.") + + added_row.count = row.delta + added_row.date = row.event_date.date() + added_row.note = normalized_note + continue + + if row.delta >= 0: + raise HTTPException( + status_code=422, + detail="Work order rows must have a negative change.", + ) + + parts_used_row = db.scalars( + select(PartsUsed).where( + PartsUsed.id == row.ref_id, + PartsUsed.part_id == part_id, + ) + ).first() + if not parts_used_row: + raise HTTPException(status_code=404, detail="Parts used row not found.") + + activity = db.scalars( + select(MeterActivities).where( + MeterActivities.id == parts_used_row.meter_activity_id + ) + ).first() + if not activity: + raise HTTPException( + status_code=404, + detail="Meter activity for parts used row not found.", + ) + + original_start = activity.timestamp_start + original_end = activity.timestamp_end + duration = original_end - original_start if original_end and original_start else None + + parts_used_row.count = abs(row.delta) + activity.timestamp_start = row.event_date + activity.description = normalized_note + if duration is not None: + activity.timestamp_end = row.event_date + duration + else: + activity.timestamp_end = row.event_date + + db.commit() + return _build_part_history_response(part_id, db) diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 012f4c88..deca5789 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -70,6 +70,18 @@ class PartHistoryRow(ORMBase): total_after: int +class PartHistoryUpdateRow(ORMBase): + ref_id: int + event_date: datetime + event_type: Literal["added", "used"] + note: str | None = None + delta: int + + +class PartHistoryUpdateRequest(ORMBase): + rows: List[PartHistoryUpdateRow] + + class PartHistoryResponse(ORMBase): part_id: int part_number: str diff --git a/frontend/src/interfaces/PartHistoryResponse.ts b/frontend/src/interfaces/PartHistoryResponse.ts index f516c1b0..1e9a4761 100644 --- a/frontend/src/interfaces/PartHistoryResponse.ts +++ b/frontend/src/interfaces/PartHistoryResponse.ts @@ -1,14 +1,27 @@ export type PartHistoryRow = { - id: string; + row_id: string; part_id: number; event_date: string; event_type: "initial" | "added" | "used"; ref_id?: number | null; + work_order_id?: number | null; note?: string | null; delta: number; total_after: number; }; +export type EditablePartHistoryRow = { + ref_id: number; + event_date: string; + event_type: "added" | "used"; + note?: string | null; + delta: number; +}; + +export type UpdatePartHistoryPayload = { + rows: EditablePartHistoryRow[]; +}; + export type PartHistoryResponse = { part_id: number; part_number: string; diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index ae6a3d89..88e732f4 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -55,7 +55,10 @@ import { IncreaseQuantityPayload } from "@/interfaces"; import { WorkOrderStatus } from "@/enums"; import { API_URL } from "@/config"; import { useNavigate } from "@tanstack/react-router"; -import { PartHistoryResponse } from "@/interfaces/PartHistoryResponse"; +import { + PartHistoryResponse, + UpdatePartHistoryPayload, +} from "@/interfaces/PartHistoryResponse"; // Date display util export function toGMT6String(date: Date) { @@ -1729,3 +1732,59 @@ export function useGetPartHistory(partId?: string) { { enabled: !!partId, keepPreviousData: true }, ); } + +export function useUpdatePartHistory( + partId?: string, + onSuccess?: (response: PartHistoryResponse) => void, +) { + const { enqueueSnackbar } = useSnackbar(); + const authHeader = useAuthHeader(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: UpdatePartHistoryPayload) => { + if (!partId) { + throw new Error("Missing part id"); + } + + const response = await PATCHFetch(`parts/${partId}/history`, payload, authHeader()); + + if (!response.ok) { + let detail = ""; + try { + const json = await response.json(); + detail = json?.detail ? ` (${json.detail})` : ""; + } catch {} + + if (response.status === 404) { + enqueueSnackbar(`Part history row not found${detail}`, { + variant: "error", + }); + throw new Error(`Part history row not found${detail}`); + } + + if (response.status === 422) { + enqueueSnackbar(`Invalid history update${detail}`, { + variant: "error", + }); + throw new Error(`Invalid history update${detail}`); + } + + enqueueSnackbar(`Unknown error occurred! (${response.status})${detail}`, { + variant: "error", + }); + throw new Error(`Unknown Error: ${response.status}${detail}`); + } + + const responseJson: PartHistoryResponse = await response.json(); + + queryClient.setQueryData(["parts-history", partId], responseJson); + queryClient.invalidateQueries({ queryKey: ["parts"] }); + queryClient.invalidateQueries({ queryKey: ["part"] }); + + onSuccess?.(responseJson); + return responseJson; + }, + retry: 0, + }); +} diff --git a/frontend/src/views/Parts/PartsHistory.tsx b/frontend/src/views/Parts/PartsHistory.tsx index aa08c30b..51d7ee50 100644 --- a/frontend/src/views/Parts/PartsHistory.tsx +++ b/frontend/src/views/Parts/PartsHistory.tsx @@ -14,7 +14,7 @@ import { Snackbar, Alert, } from "@mui/material"; -import { ArrowBack, History, Save, Search } from "@mui/icons-material"; +import { ArrowBack, History, PlusOne, Save, Search } from "@mui/icons-material"; import { DataGrid, GridColDef, @@ -30,11 +30,22 @@ import { EventTypeChip, ControlledDatepicker, ControlledSelectNonObject, + IncreaseQuantityModal, } from "@/components"; -import { useGetPartHistory } from "@/service"; +import { + useAddParts, + useGetPartHistory, + useGetParts, + useUpdatePartHistory, +} from "@/service"; import { useForm } from "react-hook-form"; import { DateTimePicker } from "@mui/x-date-pickers"; import { Route } from "@/routes/manage/parts/$id/history"; +import { + EditablePartHistoryRow, + PartHistoryResponse, +} from "@/interfaces/PartHistoryResponse"; +import { useSnackbar } from "notistack"; type EventType = "initial" | "used" | "added" | "current"; @@ -72,15 +83,74 @@ const defaultSchema = { )[], }; +function recalculateRows(sourceRows: any[]) { + const initialRow = sourceRows.find((row) => row.event_type === "initial"); + const currentRow = sourceRows.find((row) => row.event_type === "current"); + const historyRows = sourceRows + .filter( + (row) => row.event_type !== "initial" && row.event_type !== "current", + ) + .sort((a, b) => { + const dateDiff = + new Date(a.event_date).getTime() - new Date(b.event_date).getTime(); + if (dateDiff !== 0) return dateDiff; + return Number(a.ref_id ?? 0) - Number(b.ref_id ?? 0); + }); + + let running = Number(initialRow?.total_after ?? 0); + const nextRows = initialRow ? [{ ...initialRow, total_after: running }] : []; + + historyRows.forEach((row) => { + running += Number(row.delta ?? 0); + nextRows.push({ ...row, total_after: running }); + }); + + if (currentRow) { + nextRows.push({ ...currentRow, total_after: running }); + } + + return nextRows; +} + +function hydrateRows(data: PartHistoryResponse, partId?: string) { + const raw = data.history ?? []; + const currentRow = + data.current_count != null + ? { + row_id: `current-${partId ?? "unknown"}`, + part_id: Number(partId), + event_date: dayjs().toISOString(), + event_type: "current", + ref_id: null, + note: "Current count", + delta: 0, + total_after: data.current_count, + work_order_id: null, + } + : null; + + return recalculateRows(currentRow ? [...raw, currentRow] : raw); +} + export const PartsHistory = () => { const { id } = useParams({ from: "/manage/parts/$id/history" }); const navigate = useNavigate(); const search = Route.useSearch(); const history = useGetPartHistory(id); + const partsList = useGetParts(); + const addParts = useAddParts(); + const { enqueueSnackbar } = useSnackbar(); + const updateHistory = useUpdatePartHistory(id, (response) => { + const nextRows = hydrateRows(response, id); + setRows(nextRows); + setOriginalRows(nextRows); + setHasChanges(false); + }); const [rows, setRows] = useState([]); const [originalRows, setOriginalRows] = useState([]); const [hasChanges, setHasChanges] = useState(false); + const [increaseOpen, setIncreaseOpen] = useState(false); const [snackbar, setSnackbar] = useState<{ message: string; severity: "success" | "error"; @@ -94,9 +164,7 @@ export const PartsHistory = () => { const defaultValues = useMemo( () => ({ from: search.from ? dayjs(search.from, "YYYY-MM-DD") : null, - to: search.to - ? dayjs(search.to, "YYYY-MM-DD") - : dayjs().endOf("month"), + to: search.to ? dayjs(search.to, "YYYY-MM-DD") : dayjs().endOf("month"), event_types: search.type, }), [search.from, search.to, search.type], @@ -122,25 +190,9 @@ export const PartsHistory = () => { useEffect(() => { if (!history.data) return; - const raw = history.data.history ?? []; - const currentRow = - history.data.current_count != null - ? { - row_id: `current-${id ?? "unknown"}`, - part_id: Number(id), - event_date: dayjs().toISOString(), - event_type: "current", - ref_id: null, - note: "Current count", - delta: 0, - total_after: history.data.current_count, - work_order_id: null, - } - : null; - - const withCurrent = currentRow ? [...raw, currentRow] : raw; - setRows(withCurrent); - setOriginalRows(withCurrent); // snapshot on load + const nextRows = hydrateRows(history.data, id); + setRows(nextRows); + setOriginalRows(nextRows); setHasChanges(false); }, [history.data, id]); @@ -209,7 +261,17 @@ export const PartsHistory = () => { } setRows((prevRows) => - prevRows.map((r) => (r.row_id === newRow.row_id ? { ...newRow } : r)), + recalculateRows( + prevRows.map((r) => + r.row_id === newRow.row_id + ? { + ...newRow, + delta: Number(newRow.delta ?? 0), + note: newRow.note ?? null, + } + : r, + ), + ), ); setHasChanges(true); @@ -218,15 +280,44 @@ export const PartsHistory = () => { const handleSave = async () => { try { - // Example: await updatePartHistory(id, rows); - // For now, just simulate success - setOriginalRows(rows); // accept changes - setHasChanges(false); + const changedRows = rows + .filter( + (row) => row.event_type === "added" || row.event_type === "used", + ) + .filter((row) => { + const originalRow = originalRows.find( + (candidate) => candidate.row_id === row.row_id, + ); + + if (!originalRow) return false; + + return ( + Number(originalRow.delta) !== Number(row.delta) || + (originalRow.note ?? "") !== (row.note ?? "") || + !dayjs(originalRow.event_date).isSame(dayjs(row.event_date)) + ); + }) + .map( + (row): EditablePartHistoryRow => ({ + ref_id: Number(row.ref_id), + event_type: row.event_type, + event_date: dayjs(row.event_date).toISOString(), + note: row.note ?? null, + delta: Number(row.delta), + }), + ); + + if (!changedRows.length) { + setHasChanges(false); + return; + } + + await updateHistory.mutateAsync({ rows: changedRows }); setSnackbar({ message: "Changes saved successfully", severity: "success", }); - } catch (err) { + } catch { setSnackbar({ message: "Failed to save changes", severity: "error" }); } }; @@ -381,13 +472,37 @@ export const PartsHistory = () => { - - - - - - - + + + + + + + + + + { variant="contained" color="success" onClick={handleSave} + disabled={updateHistory.isLoading} sx={{ flexShrink: 0, width: { xs: "100%", sm: "auto" }, @@ -564,6 +680,32 @@ export const PartsHistory = () => { {snackbar?.message} + setIncreaseOpen(false)} + parts={partsList.data ?? []} + defaultPartId={id ? Number(id) : undefined} + loading={addParts.isLoading} + onSubmit={(payload) => { + addParts.mutate(payload, { + onSuccess: async () => { + enqueueSnackbar("Quantity increase submitted successfully.", { + variant: "success", + }); + setIncreaseOpen(false); + await Promise.all([partsList.refetch(), history.refetch()]); + }, + onError: () => { + enqueueSnackbar( + "Failed to submit quantity increase. Please try again.", + { + variant: "error", + }, + ); + }, + }); + }} + /> ); }; From 5d9e91dad33078cbc7e4406f88447199bbd73a50 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 00:17:48 -0600 Subject: [PATCH 52/91] feat(workorders): Add role grouping --- frontend/src/utils/UserRoleGrouping.ts | 33 +++++++++ .../src/views/Reports/Maintenance/index.tsx | 32 +------- .../src/views/WorkOrders/WorkOrdersTable.tsx | 73 ++++++++++++------- 3 files changed, 83 insertions(+), 55 deletions(-) create mode 100644 frontend/src/utils/UserRoleGrouping.ts diff --git a/frontend/src/utils/UserRoleGrouping.ts b/frontend/src/utils/UserRoleGrouping.ts new file mode 100644 index 00000000..be4a7b7a --- /dev/null +++ b/frontend/src/utils/UserRoleGrouping.ts @@ -0,0 +1,33 @@ +import { ROLE_IDS } from "@/config"; +import { User } from "@/interfaces"; + +export type RoleLabel = "Admin" | "Technician" | "OSE" | "Unknown"; + +export const getRoleLabel = (user: User): RoleLabel => { + switch (user.user_role_id) { + case ROLE_IDS.ADMIN: + return "Admin"; + case ROLE_IDS.TECHNICIAN: + return "Technician"; + case ROLE_IDS.OSE: + return "OSE"; + default: + return "Unknown"; + } +}; + +export const roleOrder: Record = { + Admin: 2, + Technician: 1, + OSE: 3, + Unknown: 99, +}; + +export const sortUsersByRoleThenName = (users: User[]): User[] => { + return [...users].sort((a, b) => { + const roleCompare = roleOrder[getRoleLabel(a)] - roleOrder[getRoleLabel(b)]; + if (roleCompare !== 0) return roleCompare; + + return (a.full_name ?? "").localeCompare(b.full_name ?? ""); + }); +}; diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 13d34b8a..caf6118a 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -36,6 +36,7 @@ import { } from "@/components"; import { API_URL, ROLE_IDS } from "@/config"; import { User } from "@/interfaces"; +import { getRoleLabel, sortUsersByRoleThenName } from "@/utils/UserRoleGrouping"; type FormValues = { from: Dayjs; @@ -104,14 +105,7 @@ export const MaintenanceReportView = () => { const technicianOptions = useMemo(() => { const users = (techiciansQuery.data ?? []) as User[]; - - return [...users].sort((a, b) => { - const ra = roleOrder[getRoleLabel(a)]; - const rb = roleOrder[getRoleLabel(b)]; - if (ra !== rb) return ra - rb; - - return (a.full_name ?? "").localeCompare(b.full_name ?? ""); - }); + return sortUsersByRoleThenName(users); }, [techiciansQuery.data]); // URL -> RHF default values (technicians are hydrated after users load) @@ -633,25 +627,3 @@ export const MaintenanceReportView = () => { ); }; - -type RoleLabel = "Admin" | "Technician" | "OSE" | "Unknown"; - -const getRoleLabel = (u: User): RoleLabel => { - switch (u.user_role_id) { - case ROLE_IDS.ADMIN: - return "Admin"; - case ROLE_IDS.TECHNICIAN: - return "Technician"; - case ROLE_IDS.OSE: - return "OSE"; - default: - return "Unknown"; - } -}; - -const roleOrder: Record = { - Admin: 2, - Technician: 1, - OSE: 3, - Unknown: 99, -}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 01053b21..139bc60e 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Delete, Add, Handyman, Clear } from "@mui/icons-material"; import { DataGrid, @@ -27,9 +27,13 @@ import { } from "@mui/material"; import { GridFooterWithButton } from "@/components"; import { Create } from "@/components/Modals/WorkOrders"; -import { MeterActivity, NewWorkOrder } from "@/interfaces"; +import { MeterActivity, NewWorkOrder, User } from "@/interfaces"; import { useSnackbar } from "notistack"; import { Route } from "@/routes/workorders"; +import { + getRoleLabel, + sortUsersByRoleThenName, +} from "@/utils/UserRoleGrouping"; const STATUS_OPTIONS: WorkOrderStatus[] = [ WorkOrderStatus.Open, @@ -55,6 +59,10 @@ export const WorkOrdersTable = () => { const userList = useGetUserList(); + const sortedUsers = useMemo(() => { + return sortUsersByRoleThenName((userList.data ?? []) as User[]); + }, [userList.data]); + const getUserFromID = (id: number | undefined) => userList.data?.find((u) => u.id === id)?.full_name ?? ""; @@ -315,7 +323,7 @@ export const WorkOrdersTable = () => { minWidth: 200, valueGetter: (id: number) => getUserFromID(id), type: "singleSelect", - valueOptions: userList.data?.map((user) => user.full_name) ?? [], + valueOptions: sortedUsers.map((user) => user.full_name), editable: hasAdminScope, }, { @@ -426,28 +434,43 @@ export const WorkOrdersTable = () => { sx={{ minWidth: 260 }} renderInput={(params) => } /> - u.full_name) ?? []} - value={assigned_user_id ? getUserFromID(assigned_user_id) : null} - onChange={(_, name) => { - const id = name ? getUserIDfromName(name) : undefined; - setSearch((p) => ({ ...p, assigned_user_id: id, page: 0 })); - }} - sx={{ minWidth: 260 }} - renderInput={(params) => ( - - )} - /> - + {hasAdminScope && ( + getRoleLabel(option)} + getOptionLabel={(option: User) => option.full_name ?? ""} + isOptionEqualToValue={(option: User, value: User) => + option.id === value.id + } + value={ + assigned_user_id + ? (sortedUsers.find((u) => u.id === assigned_user_id) ?? null) + : null + } + onChange={(_, user) => { + const id = user?.id; + setSearch((p) => ({ ...p, assigned_user_id: id, page: 0 })); + }} + sx={{ minWidth: 260 }} + renderOption={(props, option) => ( +
  • + {option.full_name} +
  • + )} + renderInput={(params) => ( + + )} + /> + )} Date: Tue, 3 Mar 2026 00:55:08 -0600 Subject: [PATCH 53/91] chore(RouteErrorView): Add helpful error pg --- frontend/src/routeTree.gen.ts | 21 +++ frontend/src/routes/__root.tsx | 13 +- .../src/routes/internal/error-preview.tsx | 11 ++ .../src/views/Reports/Maintenance/index.tsx | 5 +- frontend/src/views/RouteErrorView.tsx | 176 ++++++++++++++++++ frontend/src/views/index.ts | 1 + 6 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/internal/error-preview.tsx create mode 100644 frontend/src/views/RouteErrorView.tsx diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 36af9cb7..00f0066e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as ManageUsersRouteImport } from './routes/manage/users' import { Route as ManagePartsRouteImport } from './routes/manage/parts' import { Route as ManageMetersRouteImport } from './routes/manage/meters' import { Route as ManageBackupsRouteImport } from './routes/manage/backups' +import { Route as InternalErrorPreviewRouteImport } from './routes/internal/error-preview' import { Route as ManagePartsIndexRouteImport } from './routes/manage/parts/index' import { Route as ManagePartsIdHistoryRouteImport } from './routes/manage/parts/$id/history' import { Route as ActivitiesActivity_idPhotosPhoto_file_nameRouteImport } from './routes/activities/$activity_id/photos/$photo_file_name' @@ -115,6 +116,11 @@ const ManageBackupsRoute = ManageBackupsRouteImport.update({ path: '/manage/backups', getParentRoute: () => rootRouteImport, } as any) +const InternalErrorPreviewRoute = InternalErrorPreviewRouteImport.update({ + id: '/internal/error-preview', + path: '/internal/error-preview', + getParentRoute: () => rootRouteImport, +} as any) const ManagePartsIndexRoute = ManagePartsIndexRouteImport.update({ id: '/', path: '/', @@ -140,6 +146,7 @@ export interface FileRoutesByFullPath { '/monitoringwells': typeof MonitoringwellsRoute '/settings': typeof SettingsRoute '/workorders': typeof WorkordersRoute + '/internal/error-preview': typeof InternalErrorPreviewRoute '/manage/backups': typeof ManageBackupsRoute '/manage/meters': typeof ManageMetersRoute '/manage/parts': typeof ManagePartsRouteWithChildren @@ -162,6 +169,7 @@ export interface FileRoutesByTo { '/monitoringwells': typeof MonitoringwellsRoute '/settings': typeof SettingsRoute '/workorders': typeof WorkordersRoute + '/internal/error-preview': typeof InternalErrorPreviewRoute '/manage/backups': typeof ManageBackupsRoute '/manage/meters': typeof ManageMetersRoute '/manage/users': typeof ManageUsersRoute @@ -184,6 +192,7 @@ export interface FileRoutesById { '/monitoringwells': typeof MonitoringwellsRoute '/settings': typeof SettingsRoute '/workorders': typeof WorkordersRoute + '/internal/error-preview': typeof InternalErrorPreviewRoute '/manage/backups': typeof ManageBackupsRoute '/manage/meters': typeof ManageMetersRoute '/manage/parts': typeof ManagePartsRouteWithChildren @@ -208,6 +217,7 @@ export interface FileRouteTypes { | '/monitoringwells' | '/settings' | '/workorders' + | '/internal/error-preview' | '/manage/backups' | '/manage/meters' | '/manage/parts' @@ -230,6 +240,7 @@ export interface FileRouteTypes { | '/monitoringwells' | '/settings' | '/workorders' + | '/internal/error-preview' | '/manage/backups' | '/manage/meters' | '/manage/users' @@ -251,6 +262,7 @@ export interface FileRouteTypes { | '/monitoringwells' | '/settings' | '/workorders' + | '/internal/error-preview' | '/manage/backups' | '/manage/meters' | '/manage/parts' @@ -274,6 +286,7 @@ export interface RootRouteChildren { MonitoringwellsRoute: typeof MonitoringwellsRoute SettingsRoute: typeof SettingsRoute WorkordersRoute: typeof WorkordersRoute + InternalErrorPreviewRoute: typeof InternalErrorPreviewRoute ManageBackupsRoute: typeof ManageBackupsRoute ManageMetersRoute: typeof ManageMetersRoute ManagePartsRoute: typeof ManagePartsRouteWithChildren @@ -407,6 +420,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ManageBackupsRouteImport parentRoute: typeof rootRouteImport } + '/internal/error-preview': { + id: '/internal/error-preview' + path: '/internal/error-preview' + fullPath: '/internal/error-preview' + preLoaderRoute: typeof InternalErrorPreviewRouteImport + parentRoute: typeof rootRouteImport + } '/manage/parts/': { id: '/manage/parts/' path: '/' @@ -466,6 +486,7 @@ const rootRouteChildren: RootRouteChildren = { MonitoringwellsRoute: MonitoringwellsRoute, SettingsRoute: SettingsRoute, WorkordersRoute: WorkordersRoute, + InternalErrorPreviewRoute: InternalErrorPreviewRoute, ManageBackupsRoute: ManageBackupsRoute, ManageMetersRoute: ManageMetersRoute, ManagePartsRoute: ManagePartsRouteWithChildren, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index b21ef1fe..e2fd897c 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,6 +1,14 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { + createRootRoute, + ErrorComponentProps, + Outlet, +} from "@tanstack/react-router"; import { AppLayout } from "@/AppLayout"; -import { NotFound } from "@/views"; +import { NotFound, RouteErrorView } from "@/views"; + +const RootErrorComponent = ({ error, reset }: ErrorComponentProps) => { + return ; +}; export const Route = createRootRoute({ component: () => ( @@ -8,5 +16,6 @@ export const Route = createRootRoute({ ), + errorComponent: RootErrorComponent, notFoundComponent: NotFound, }); diff --git a/frontend/src/routes/internal/error-preview.tsx b/frontend/src/routes/internal/error-preview.tsx new file mode 100644 index 00000000..437e9fe5 --- /dev/null +++ b/frontend/src/routes/internal/error-preview.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/internal/error-preview")({ + component: ErrorPreviewComponent, +}); + +function ErrorPreviewComponent() { + throw new Error( + "Internal preview route crash. Use this page only to verify the styled router error screen and copied URL details.", + ); +} diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index caf6118a..ace93078 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -36,7 +36,10 @@ import { } from "@/components"; import { API_URL, ROLE_IDS } from "@/config"; import { User } from "@/interfaces"; -import { getRoleLabel, sortUsersByRoleThenName } from "@/utils/UserRoleGrouping"; +import { + getRoleLabel, + sortUsersByRoleThenName, +} from "@/utils/UserRoleGrouping"; type FormValues = { from: Dayjs; diff --git a/frontend/src/views/RouteErrorView.tsx b/frontend/src/views/RouteErrorView.tsx new file mode 100644 index 00000000..62189b4a --- /dev/null +++ b/frontend/src/views/RouteErrorView.tsx @@ -0,0 +1,176 @@ +import { useMemo } from "react"; +import { + Box, + Button, + Card, + CardContent, + Stack, + Typography, +} from "@mui/material"; +import { ContentCopy, Home, Refresh, Warning } from "@mui/icons-material"; +import { Link } from "@tanstack/react-router"; +import { useSnackbar } from "notistack"; +import { BackgroundBox, CustomCardHeader } from "@/components"; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + if (error && typeof error === "object") { + try { + return JSON.stringify(error, null, 2); + } catch { + return "An unknown routing error occurred."; + } + } + + return "An unknown routing error occurred."; +}; + +const getExactUrl = (): string => { + if (typeof window === "undefined") { + return "Unavailable during server rendering"; + } + + return window.location.href; +}; + +type RouteErrorViewProps = { + error: unknown; + onRetry?: () => void; +}; + +export const RouteErrorView = ({ error, onRetry }: RouteErrorViewProps) => { + const { enqueueSnackbar } = useSnackbar(); + const errorMessage = useMemo(() => getErrorMessage(error), [error]); + const exactUrl = useMemo(() => getExactUrl(), []); + + const copyValue = async (label: string, value: string) => { + try { + await navigator.clipboard.writeText(value); + enqueueSnackbar(`${label} copied.`, { variant: "success" }); + } catch { + enqueueSnackbar(`Unable to copy ${label.toLowerCase()}.`, { + variant: "error", + }); + } + }; + + return ( + + + + + + + + + We encountered an unexpected error while loading this page. + + + + To help us resolve this as quickly as possible, please copy + the Error Message and the{" "} + Exact URL below and include them when + reporting this issue to support. + + + + + + + Error Message + + + {errorMessage} + + + + + + + Exact URL + + + {exactUrl} + + + + + + {onRetry && ( + + )} + + + + + + ); +}; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index a73064fe..035a9693 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -7,6 +7,7 @@ export * from "./Meters"; export * from "./NotFound"; export * from "./Parts"; export * from "./Reports"; +export * from "./RouteErrorView"; export * from "./Settings"; export * from "./UserManagement"; export * from "./WellManagement"; From 5b58b231493babbb8ade4832b8e84b102631bd70 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 19:04:38 -0600 Subject: [PATCH 54/91] fix(Chlorides): Patch broken links in manage meter page --- frontend/src/views/Chlorides/ChloridesPlot.tsx | 4 ++-- frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx | 4 ++-- frontend/src/views/WellManagement/WellSelectionTable.tsx | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/Chlorides/ChloridesPlot.tsx b/frontend/src/views/Chlorides/ChloridesPlot.tsx index 8a500006..3b6b8d24 100644 --- a/frontend/src/views/Chlorides/ChloridesPlot.tsx +++ b/frontend/src/views/Chlorides/ChloridesPlot.tsx @@ -34,11 +34,11 @@ export const Plot = ({ }, [manual_dates, manual_vals]); return ( - + {isLoading ? ( + {isLoading ? ( { const meters = params.value as Well["meters"]; const links = meters.map((meter, index) => ( - + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > ({ From eb7d485c481ad11b90cf714f3ba60caea8f01e40 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 19:20:21 -0600 Subject: [PATCH 55/91] feat(ApiServiceNew): Add caching to save load times --- frontend/src/service/ApiServiceNew.ts | 119 +++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 88e732f4..2bdb255e 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1,4 +1,5 @@ import { + InfiniteData, useInfiniteQuery, useMutation, useQuery, @@ -60,6 +61,81 @@ import { UpdatePartHistoryPayload, } from "@/interfaces/PartHistoryResponse"; +// Cashe for up to 48 hours +const MAP_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 2; +const MAP_CACHE_PREFIX = "wmdb:map-cache:"; +const MAP_QUERY_ROUTES = ["meters_locations", "well_locations"] as const; + +type StoredMapCache = { + data: T; + updatedAt: number; +}; + +function getMapCacheStorageKey(queryKey: readonly unknown[]) { + return `${MAP_CACHE_PREFIX}${JSON.stringify(queryKey)}`; +} + +function readMapCache(queryKey: readonly unknown[]) { + if (typeof window === "undefined") return undefined; + + const storageKey = getMapCacheStorageKey(queryKey); + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) return undefined; + + try { + const parsed = JSON.parse(rawValue) as StoredMapCache; + if ( + !parsed || + typeof parsed.updatedAt !== "number" || + Date.now() - parsed.updatedAt > MAP_CACHE_TTL_MS + ) { + window.localStorage.removeItem(storageKey); + return undefined; + } + + return parsed; + } catch { + window.localStorage.removeItem(storageKey); + return undefined; + } +} + +function writeMapCache(queryKey: readonly unknown[], data: T) { + if (typeof window === "undefined") return; + + const storageKey = getMapCacheStorageKey(queryKey); + const value: StoredMapCache = { + data, + updatedAt: Date.now(), + }; + + window.localStorage.setItem(storageKey, JSON.stringify(value)); +} + +function clearStoredMapCaches() { + if (typeof window === "undefined") return; + + const keysToRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (key?.startsWith(MAP_CACHE_PREFIX)) { + keysToRemove.push(key); + } + } + + keysToRemove.forEach((key) => window.localStorage.removeItem(key)); +} + +function invalidateMapDataCaches( + queryClient: ReturnType, +) { + clearStoredMapCaches(); + MAP_QUERY_ROUTES.forEach((route) => { + queryClient.removeQueries(route); + queryClient.invalidateQueries(route); + }); +} + // Date display util export function toGMT6String(date: Date) { const dateString = @@ -249,9 +325,11 @@ export function useGetMeterLocations(searchstring: string | undefined) { const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); + const queryKey = [route, searchstring] as const; + const cachedData = readMapCache(queryKey); return useQuery({ - queryKey: [route, searchstring], + queryKey, queryFn: () => GETFetch( route, @@ -260,8 +338,11 @@ export function useGetMeterLocations(searchstring: string | undefined) { signOut, navigate, ), - staleTime: 1000 * 60 * 60 * 24, // 24 hours - cacheTime: 1000 * 60 * 60 * 24, // keep in memory for 24 hours + initialData: cachedData?.data, + initialDataUpdatedAt: cachedData?.updatedAt, + onSuccess: (data) => writeMapCache(queryKey, data), + staleTime: MAP_CACHE_TTL_MS, + cacheTime: MAP_CACHE_TTL_MS, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, @@ -479,9 +560,11 @@ export function useGetWellLocations( const navigate = useNavigate(); const signOut = useSignOut(); const PAGE_SIZE = 500; + const queryKey = [route, searchstring, has_chloride_group] as const; + const cachedData = readMapCache>(queryKey); return useInfiniteQuery({ - queryKey: [route, searchstring, has_chloride_group], + queryKey, queryFn: async ({ pageParam = 0 }) => { return GETFetch( route, @@ -501,8 +584,11 @@ export function useGetWellLocations( if (!lastPage || lastPage.length < PAGE_SIZE) return undefined; return allPages.length * PAGE_SIZE; // next offset }, - staleTime: 1000 * 60 * 60 * 24, - cacheTime: 1000 * 60 * 60 * 24, + initialData: cachedData?.data, + initialDataUpdatedAt: cachedData?.updatedAt, + onSuccess: (data) => writeMapCache(queryKey, data), + staleTime: MAP_CACHE_TTL_MS, + cacheTime: MAP_CACHE_TTL_MS, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, @@ -732,6 +818,7 @@ export function useCreateWell(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "wells"; const authHeader = useAuthHeader(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (new_well: SubmitWellCreate) => { @@ -756,6 +843,7 @@ export function useCreateWell(onSuccess: Function) { } else { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); return responseJson; } }, @@ -829,6 +917,7 @@ export function useUpdateWell(onSuccess: Function) { } else { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); // Since query data will be based on params, iterate through all possible queries of this route const wellsQueries = queryClient.getQueryCache().findAll("wells"); @@ -995,6 +1084,7 @@ export function useCreateMeter(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "meters"; const authHeader = useAuthHeader(); + const queryClient = useQueryClient(); return useMutation({ mutationFn: async (meter: Meter) => { @@ -1020,6 +1110,7 @@ export function useCreateMeter(onSuccess: Function) { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); return responseJson; } }, @@ -1152,6 +1243,7 @@ export function useUpdateMeter(onSuccess: Function) { onSuccess(); const responseJson = await response.json(); + invalidateMapDataCaches(queryClient); // Since query data will be based on params, iterate through all possible queries of this route const meterQueries = queryClient.getQueryCache().findAll("meters"); @@ -1747,7 +1839,11 @@ export function useUpdatePartHistory( throw new Error("Missing part id"); } - const response = await PATCHFetch(`parts/${partId}/history`, payload, authHeader()); + const response = await PATCHFetch( + `parts/${partId}/history`, + payload, + authHeader(), + ); if (!response.ok) { let detail = ""; @@ -1770,9 +1866,12 @@ export function useUpdatePartHistory( throw new Error(`Invalid history update${detail}`); } - enqueueSnackbar(`Unknown error occurred! (${response.status})${detail}`, { - variant: "error", - }); + enqueueSnackbar( + `Unknown error occurred! (${response.status})${detail}`, + { + variant: "error", + }, + ); throw new Error(`Unknown Error: ${response.status}${detail}`); } From 02193ccf5932ccdeef2f13ae83b37e7234617bf4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 19:48:41 -0600 Subject: [PATCH 56/91] feat(Layers): Add Boundaries & Transportation layers --- frontend/src/components/Layers/BoundariesLayer.tsx | 10 ++++++++++ frontend/src/components/Layers/SatelliteLayer.tsx | 8 ++++---- frontend/src/components/Layers/TransportationLayer.tsx | 10 ++++++++++ frontend/src/components/Layers/index.ts | 9 +++++---- frontend/src/views/WellManagement/WellSelectionMap.tsx | 4 ++++ 5 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/Layers/BoundariesLayer.tsx create mode 100644 frontend/src/components/Layers/TransportationLayer.tsx diff --git a/frontend/src/components/Layers/BoundariesLayer.tsx b/frontend/src/components/Layers/BoundariesLayer.tsx new file mode 100644 index 00000000..4449c10e --- /dev/null +++ b/frontend/src/components/Layers/BoundariesLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet"; + +export const BoundariesLayer = () => ( + + + +); diff --git a/frontend/src/components/Layers/SatelliteLayer.tsx b/frontend/src/components/Layers/SatelliteLayer.tsx index d22221ca..541b57b2 100644 --- a/frontend/src/components/Layers/SatelliteLayer.tsx +++ b/frontend/src/components/Layers/SatelliteLayer.tsx @@ -1,10 +1,10 @@ -import { LayersControl, TileLayer } from "react-leaflet" +import { LayersControl, TileLayer } from "react-leaflet"; export const SatelliteLayer = () => ( - + -) +); diff --git a/frontend/src/components/Layers/TransportationLayer.tsx b/frontend/src/components/Layers/TransportationLayer.tsx new file mode 100644 index 00000000..a863a0fc --- /dev/null +++ b/frontend/src/components/Layers/TransportationLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet"; + +export const TransporationLayer = () => ( + + + +); diff --git a/frontend/src/components/Layers/index.ts b/frontend/src/components/Layers/index.ts index a4be50d8..e29313ec 100644 --- a/frontend/src/components/Layers/index.ts +++ b/frontend/src/components/Layers/index.ts @@ -1,4 +1,5 @@ -export * from './SoutheastGuideLayer' -export * from './SatelliteLayer' -export * from './OpenStreetMapLayer' - +export * from "./SoutheastGuideLayer"; +export * from "./SatelliteLayer"; +export * from "./BoundariesLayer"; +export * from "./TransportationLayer"; +export * from "./OpenStreetMapLayer"; diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index 46bacf38..4cb55a54 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -6,9 +6,11 @@ import { useNavigate } from "@tanstack/react-router"; import { useGetWellLocations } from "@/service"; import { Well } from "@/interfaces"; import { + BoundariesLayer, OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, + TransporationLayer, WellMapLegend, } from "@/components"; import { BlueMapIcon, RedMapIcon, BlackMapIcon } from "@/components/MapIcons"; @@ -121,6 +123,8 @@ export default function WellSelectionMap({ ))} + + From bebe53f57174f6ad17cef030668d4be2ee23857e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 20:17:21 -0600 Subject: [PATCH 57/91] feat(Layers): Save the map state in the url --- .../src/components/Layers/BoundariesLayer.tsx | 4 +- .../components/Layers/OpenStreetMapLayer.tsx | 8 +- .../src/components/Layers/SatelliteLayer.tsx | 4 +- .../components/Layers/SoutheastGuideLayer.tsx | 8 +- .../components/Layers/TransportationLayer.tsx | 4 +- frontend/src/components/MapUrlStateSync.tsx | 220 ++++++++++++++++++ .../src/components/MeterMapColorLegend.tsx | 6 +- frontend/src/components/index.ts | 1 + frontend/src/routes/manage/meters.tsx | 12 + frontend/src/routes/manage/wells.tsx | 14 ++ frontend/src/routes/reports/chlorides.tsx | 12 + frontend/src/utils/MapUrlState.ts | 109 +++++++++ frontend/src/utils/index.ts | 1 + .../MeterSelection/MeterSelectionMap.tsx | 91 +++++++- .../src/views/Reports/Chlorides/index.tsx | 77 +++++- .../views/WellManagement/WellSelectionMap.tsx | 84 ++++++- 16 files changed, 614 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/MapUrlStateSync.tsx create mode 100644 frontend/src/utils/MapUrlState.ts diff --git a/frontend/src/components/Layers/BoundariesLayer.tsx b/frontend/src/components/Layers/BoundariesLayer.tsx index 4449c10e..f22096cb 100644 --- a/frontend/src/components/Layers/BoundariesLayer.tsx +++ b/frontend/src/components/Layers/BoundariesLayer.tsx @@ -1,7 +1,7 @@ import { LayersControl, TileLayer } from "react-leaflet"; -export const BoundariesLayer = () => ( - +export const BoundariesLayer = ({ checked = false }: { checked?: boolean }) => ( + ( - +export const OpenStreetMapLayer = ({ checked = false }: { checked?: boolean }) => ( + -) +); diff --git a/frontend/src/components/Layers/SatelliteLayer.tsx b/frontend/src/components/Layers/SatelliteLayer.tsx index 541b57b2..dabb737d 100644 --- a/frontend/src/components/Layers/SatelliteLayer.tsx +++ b/frontend/src/components/Layers/SatelliteLayer.tsx @@ -1,7 +1,7 @@ import { LayersControl, TileLayer } from "react-leaflet"; -export const SatelliteLayer = () => ( - +export const SatelliteLayer = ({ checked = false }: { checked?: boolean }) => ( + ">${text}`, }); -export const SoutheastGuideLayer = () => +export const SoutheastGuideLayer = ({ + checked = false, +}: { + checked?: boolean; +}) => ( - + {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} diff --git a/frontend/src/components/Layers/TransportationLayer.tsx b/frontend/src/components/Layers/TransportationLayer.tsx index a863a0fc..6a6cbcf8 100644 --- a/frontend/src/components/Layers/TransportationLayer.tsx +++ b/frontend/src/components/Layers/TransportationLayer.tsx @@ -1,7 +1,7 @@ import { LayersControl, TileLayer } from "react-leaflet"; -export const TransporationLayer = () => ( - +export const TransporationLayer = ({ checked = false }: { checked?: boolean }) => ( + = { + allowedBaseLayers: readonly string[]; + allowedOverlays: readonly string[]; + defaultBaseLayer: string; + defaultOverlays: string[]; + search: TSearch; + setSearch: (updater: (prev: TSearch) => TSearch) => void; +}; + +const sortNames = (names: string[]) => [...names].sort(); + +const arraysEqual = (left: string[], right: string[]) => + left.length === right.length && + left.every((value, index) => value === right[index]); + +export const MapUrlStateSync = ({ + allowedBaseLayers, + allowedOverlays, + defaultBaseLayer, + defaultOverlays, + search, + setSearch, +}: MapUrlStateSyncProps) => { + const map = useMap(); + const searchRef = useRef(search); + const activeBaseLayerRef = useRef( + normalizeMapBaseLayer(search.mapBase, allowedBaseLayers, defaultBaseLayer), + ); + const activeOverlaysRef = useRef( + normalizeMapOverlayNames( + search.mapOverlays, + allowedOverlays, + defaultOverlays, + ), + ); + + const normalizedDefaults = useMemo( + () => ({ + baseLayer: normalizeMapBaseLayer( + defaultBaseLayer, + allowedBaseLayers, + defaultBaseLayer, + ), + overlays: normalizeMapOverlayNames( + defaultOverlays, + allowedOverlays, + defaultOverlays, + ), + view: { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }, + }), + [allowedBaseLayers, allowedOverlays, defaultBaseLayer, defaultOverlays], + ); + + useEffect(() => { + searchRef.current = search; + activeBaseLayerRef.current = normalizeMapBaseLayer( + search.mapBase, + allowedBaseLayers, + defaultBaseLayer, + ); + activeOverlaysRef.current = normalizeMapOverlayNames( + search.mapOverlays, + allowedOverlays, + defaultOverlays, + ); + }, [ + allowedBaseLayers, + allowedOverlays, + defaultBaseLayer, + defaultOverlays, + search, + ]); + + useEffect(() => { + const nextView = parseMapView(search, normalizedDefaults.view); + const currentCenter = map.getCenter(); + const currentZoom = map.getZoom(); + + const viewChanged = + Math.abs(currentCenter.lat - nextView.center[0]) > 0.00001 || + Math.abs(currentCenter.lng - nextView.center[1]) > 0.00001 || + currentZoom !== nextView.zoom; + + if (viewChanged) { + map.setView(nextView.center, nextView.zoom, { animate: false }); + } + }, [map, normalizedDefaults.view, search]); + + useEffect(() => { + const syncSearchFromMap = () => { + const currentSearch = searchRef.current; + const baseLayer = normalizeMapBaseLayer( + activeBaseLayerRef.current, + allowedBaseLayers, + defaultBaseLayer, + ); + const overlays = normalizeMapOverlayNames( + activeOverlaysRef.current, + allowedOverlays, + defaultOverlays, + ); + const viewState = serializeMapView( + map.getCenter(), + map.getZoom(), + normalizedDefaults.view, + ); + const nextBaseLayer = + baseLayer === normalizedDefaults.baseLayer ? undefined : baseLayer; + const nextOverlays = arraysEqual(overlays, normalizedDefaults.overlays) + ? undefined + : overlays; + const currentBaseLayer = normalizeMapBaseLayer( + currentSearch.mapBase, + allowedBaseLayers, + defaultBaseLayer, + ); + const currentOverlays = normalizeMapOverlayNames( + currentSearch.mapOverlays, + allowedOverlays, + defaultOverlays, + ); + const currentView = parseMapView(currentSearch, normalizedDefaults.view); + + const baseChanged = currentBaseLayer !== baseLayer; + const overlaysChanged = !arraysEqual(currentOverlays, overlays); + const viewChanged = + Math.abs( + currentView.center[0] - + (viewState.mapLat ?? normalizedDefaults.view.center[0]), + ) > + 0.00001 || + Math.abs( + currentView.center[1] - + (viewState.mapLng ?? normalizedDefaults.view.center[1]), + ) > + 0.00001 || + currentView.zoom !== (viewState.mapZoom ?? normalizedDefaults.view.zoom); + + if (!baseChanged && !overlaysChanged && !viewChanged) { + return; + } + + setSearch((prev) => ({ + ...prev, + mapBase: nextBaseLayer, + mapOverlays: nextOverlays, + mapLat: viewState.mapLat, + mapLng: viewState.mapLng, + mapZoom: viewState.mapZoom, + })); + }; + + const handleBaseLayerChange = (event: LayersControlEvent) => { + activeBaseLayerRef.current = event.name; + syncSearchFromMap(); + }; + + const handleOverlayAdd = (event: LayersControlEvent) => { + activeOverlaysRef.current = sortNames([ + ...activeOverlaysRef.current, + event.name, + ]); + syncSearchFromMap(); + }; + + const handleOverlayRemove = (event: LayersControlEvent) => { + activeOverlaysRef.current = sortNames( + activeOverlaysRef.current.filter((name) => name !== event.name), + ); + syncSearchFromMap(); + }; + + map.on("moveend", syncSearchFromMap); + map.on("baselayerchange", handleBaseLayerChange); + map.on("overlayadd", handleOverlayAdd); + map.on("overlayremove", handleOverlayRemove); + + return () => { + map.off("moveend", syncSearchFromMap); + map.off("baselayerchange", handleBaseLayerChange); + map.off("overlayadd", handleOverlayAdd); + map.off("overlayremove", handleOverlayRemove); + }; + }, [ + allowedBaseLayers, + allowedOverlays, + defaultBaseLayer, + defaultOverlays, + map, + normalizedDefaults.baseLayer, + normalizedDefaults.overlays, + normalizedDefaults.view, + setSearch, + ]); + + return null; +}; diff --git a/frontend/src/components/MeterMapColorLegend.tsx b/frontend/src/components/MeterMapColorLegend.tsx index ef75b7d3..253e2a75 100644 --- a/frontend/src/components/MeterMapColorLegend.tsx +++ b/frontend/src/components/MeterMapColorLegend.tsx @@ -13,11 +13,11 @@ export const MeterMapColorLegend = () => { const div = L.DomUtil.create("div", "info legend"); div.style.background = "white"; - div.style.padding = "10px"; + div.style.padding = "8px"; div.style.borderRadius = "8px"; div.style.boxShadow = "0 2px 6px rgba(0,0,0,0.3)"; div.style.fontSize = "14px"; - div.style.lineHeight = "18px"; + div.style.lineHeight = "14px"; const title = L.DomUtil.create("h4", "", div); title.textContent = "PM Season"; @@ -27,7 +27,7 @@ export const MeterMapColorLegend = () => { const row = L.DomUtil.create("div", "", div); row.style.display = "flex"; row.style.alignItems = "center"; - row.style.marginBottom = "6px"; + row.style.marginBottom = "5px"; const colorBox = L.DomUtil.create("div", "", row); colorBox.style.width = "20px"; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 8dcbc894..99b1b09e 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -12,6 +12,7 @@ export * from "./ImageUploadWithPreview"; export * from "./IsTrueChip"; export * from "./Layers"; export * from "./LinkBehavior"; +export * from "./MapUrlStateSync"; export * from "./MergeWellModal"; export * from "./MeterMapColorLegend"; export * from "./MeterRegisterSelect"; diff --git a/frontend/src/routes/manage/meters.tsx b/frontend/src/routes/manage/meters.tsx index f9b7cc50..1dc32afe 100644 --- a/frontend/src/routes/manage/meters.tsx +++ b/frontend/src/routes/manage/meters.tsx @@ -2,6 +2,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { MetersView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; +import { + mapBaseLayerSchema, + mapLatSchema, + mapLngSchema, + mapOverlayNamesSchema, + mapZoomSchema, +} from "@/utils"; const intPosOptional = z.preprocess((val) => { if (val === undefined || val === null || val === "") return undefined; @@ -89,6 +96,11 @@ export const Route = createFileRoute("/manage/meters")({ // meter history pagination h_page: pageSchema, h_pageSize: pageSizeSchema, + mapBase: mapBaseLayerSchema.catch("OpenStreetMap").default("OpenStreetMap"), + mapOverlays: mapOverlayNamesSchema, + mapLat: mapLatSchema, + mapLng: mapLngSchema, + mapZoom: mapZoomSchema, }), component: () => ( diff --git a/frontend/src/routes/manage/wells.tsx b/frontend/src/routes/manage/wells.tsx index 2045ca4c..0b9ff7fb 100644 --- a/frontend/src/routes/manage/wells.tsx +++ b/frontend/src/routes/manage/wells.tsx @@ -2,6 +2,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { WellManagementView } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; +import { + mapBaseLayerSchema, + mapLatSchema, + mapLngSchema, + mapOverlayNamesSchema, + mapZoomSchema, +} from "@/utils"; const intPosOptional = z.preprocess((val) => { if (val === undefined || val === null || val === "") return undefined; @@ -53,6 +60,13 @@ export const Route = createFileRoute("/manage/wells")({ well_id: intPosOptional, page: z.coerce.number().int().min(0).catch(0), pageSize: z.coerce.number().int().min(10).max(200).catch(25), + mapBase: mapBaseLayerSchema + .catch("OpenStreetMap") + .default("OpenStreetMap"), + mapOverlays: mapOverlayNamesSchema, + mapLat: mapLatSchema, + mapLng: mapLngSchema, + mapZoom: mapZoomSchema, }) .passthrough(), component: () => ( diff --git a/frontend/src/routes/reports/chlorides.tsx b/frontend/src/routes/reports/chlorides.tsx index 51c41d1e..12ee87ba 100644 --- a/frontend/src/routes/reports/chlorides.tsx +++ b/frontend/src/routes/reports/chlorides.tsx @@ -2,6 +2,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; import { ChloridesReportView } from "@/views/Reports/Chlorides"; import { ProtectedRoute } from "@/ProtectedRoute"; +import { + mapBaseLayerSchema, + mapLatSchema, + mapLngSchema, + mapOverlayNamesSchema, + mapZoomSchema, +} from "@/utils"; const isoDate = z.preprocess((val) => { const raw = Array.isArray(val) ? val[0] : val; @@ -14,6 +21,11 @@ export const Route = createFileRoute("/reports/chlorides")({ validateSearch: z.object({ from: isoDate.catch(undefined), to: isoDate.catch(undefined), + mapBase: mapBaseLayerSchema.catch("OpenStreetMap").default("OpenStreetMap"), + mapOverlays: mapOverlayNamesSchema, + mapLat: mapLatSchema, + mapLng: mapLngSchema, + mapZoom: mapZoomSchema, }), component: () => ( diff --git a/frontend/src/utils/MapUrlState.ts b/frontend/src/utils/MapUrlState.ts new file mode 100644 index 00000000..f87ac48c --- /dev/null +++ b/frontend/src/utils/MapUrlState.ts @@ -0,0 +1,109 @@ +import { z } from "zod"; + +export const DEFAULT_MAP_CENTER: [number, number] = [33, -104]; +export const DEFAULT_MAP_ZOOM = 8; + +export const MAP_BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; + +const optionalSearchString = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const s = String(raw).trim(); + return s.length ? s : undefined; +}, z.string().optional()); + +const optionalSearchNumber = z.preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val[0] : val; + const n = Number(raw); + return Number.isFinite(n) ? n : undefined; +}, z.number().optional()); + +export const mapBaseLayerSchema = optionalSearchString; + +export const mapOverlayNamesSchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const raw = Array.isArray(val) ? val : [val]; + const items = raw + .flatMap((v) => (typeof v === "string" ? v.split(",") : [v])) + .map((v) => String(v).trim()) + .filter(Boolean); + + return items.length ? items : undefined; + }, z.array(z.string()).optional()) + .catch(undefined); + +export const mapLatSchema = optionalSearchNumber + .pipe(z.number().min(-90).max(90).optional()) + .catch(undefined); + +export const mapLngSchema = optionalSearchNumber + .pipe(z.number().min(-180).max(180).optional()) + .catch(undefined); + +export const mapZoomSchema = optionalSearchNumber + .pipe(z.number().int().min(0).max(22).optional()) + .catch(undefined); + +type MapSearchState = { + mapBase?: string; + mapOverlays?: string[]; + mapLat?: number; + mapLng?: number; + mapZoom?: number; +}; + +const roundCoordinate = (value: number) => Number(value.toFixed(5)); + +export const normalizeMapBaseLayer = ( + value: string | undefined, + allowed: readonly string[], + fallback: string, +) => (value && allowed.includes(value) ? value : fallback); + +export const normalizeMapOverlayNames = ( + value: string[] | undefined, + allowed: readonly string[], + fallback: string[], +) => { + const source = value?.length ? value : fallback; + return [...new Set(source.filter((name) => allowed.includes(name)))].sort(); +}; + +export const parseMapView = ( + search: MapSearchState, + fallback = { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }, +) => ({ + center: [ + search.mapLat ?? fallback.center[0], + search.mapLng ?? fallback.center[1], + ] as [number, number], + zoom: search.mapZoom ?? fallback.zoom, +}); + +export const serializeMapView = ( + center: { lat: number; lng: number }, + zoom: number, + fallback = { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }, +) => { + const lat = roundCoordinate(center.lat); + const lng = roundCoordinate(center.lng); + + return { + mapLat: lat === fallback.center[0] ? undefined : lat, + mapLng: lng === fallback.center[1] ? undefined : lng, + mapZoom: zoom === fallback.zoom ? undefined : zoom, + }; +}; + +export const getMapLayersControlKey = ( + baseLayerName: string, + overlayNames: string[], +) => `${baseLayerName}::${overlayNames.slice().sort().join("|")}`; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index e2601704..a33a1a77 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -3,6 +3,7 @@ export * from "./DateUtils"; export * from "./DataStreamUtils"; export * from "./EmptyToNull"; export * from "./HttpUtils"; +export * from "./MapUrlState"; export * from "./GetMeterMarkerColor"; export * from "./GetRoleColor"; export * from "./MemoryUtils"; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 8c29fb79..45c5e1a6 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -22,6 +22,7 @@ import icon from "leaflet/dist/images/marker-icon.png"; import iconShadow from "leaflet/dist/images/marker-shadow.png"; import { useGetMeterLocations } from "@/service"; import { Box, Typography } from "@mui/material"; +import { useNavigate } from "@tanstack/react-router"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; @@ -29,8 +30,31 @@ import { OpenStreetMapLayer, SatelliteLayer, MeterMapColorLegend, + TransporationLayer, + BoundariesLayer, + MapUrlStateSync, } from "@/components"; -import { getMeterMarkerColor } from "@/utils"; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + getMapLayersControlKey, + getMeterMarkerColor, + normalizeMapBaseLayer, + normalizeMapOverlayNames, + parseMapView, +} from "@/utils"; +import { Route } from "@/routes/manage/meters"; + +const BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; +const OVERLAY_NAMES = [ + "Meters", + "Section", + "Township Range", + "Transportation", + "Boundaries and Places", +] as const; +const DEFAULT_BASE_LAYER = "OpenStreetMap"; +const DEFAULT_OVERLAYS = ["Meters"]; const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); L.Marker.prototype.options.icon = DefaultIcon; @@ -46,8 +70,31 @@ export default function MeterSelectionMap({ meterSearch: string; onMeterSelection: Function; }) { + const search = Route.useSearch(); + const navigate = useNavigate(); const [meterSearchDebounced] = useDebounce(meterSearch, 250); const meterMarkers = useGetMeterLocations(meterSearchDebounced); + const mapBaseLayer = normalizeMapBaseLayer( + search.mapBase, + BASE_LAYER_NAMES, + DEFAULT_BASE_LAYER, + ); + const mapOverlayNames = normalizeMapOverlayNames( + search.mapOverlays, + OVERLAY_NAMES, + DEFAULT_OVERLAYS, + ); + const mapView = parseMapView(search, { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }); + const setSearch = (updater: (prev: typeof search) => typeof search) => { + navigate({ + to: "/manage/meters", + search: (prev) => updater(prev as typeof search), + replace: true, + }); + }; return ( <> @@ -61,18 +108,32 @@ export default function MeterSelectionMap({ }} > - + + {/* Base Layers */} - - + + {/* Markers Cluster Overlay */} - + {/* Section GeoJSON */} - + {/* Township/Range GeoJSON */} - + + + diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 5dc53738..3fe6c120 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -35,16 +35,27 @@ import { CustomCardHeader, BackgroundBox, DirectionCard, + MapUrlStateSync, SoutheastGuideLayer, SatelliteLayer, OpenStreetMapLayer, WellMapLegend, + TransporationLayer, + BoundariesLayer, } from "@/components"; import { RedMapIcon, BlackMapIcon } from "@/components/MapIcons"; import { useFetchWithAuth } from "@/hooks"; import { useGetWellLocations } from "@/service"; import { Well } from "@/interfaces"; import { WellStatus } from "@/enums"; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + getMapLayersControlKey, + normalizeMapBaseLayer, + normalizeMapOverlayNames, + parseMapView, +} from "@/utils"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; @@ -86,9 +97,33 @@ interface iChlorideReportNums { const isoToDayjs = (s?: string, fallback?: Dayjs) => s ? dayjs(s, "YYYY-MM-DD") : (fallback ?? dayjs()); +const BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; +const OVERLAY_NAMES = [ + "Wells", + "Clorides Report Region Guide", + "Transportation", + "Boundaries and Places", +] as const; +const DEFAULT_BASE_LAYER = "OpenStreetMap"; +const DEFAULT_OVERLAYS = ["Clorides Report Region Guide", "Wells"]; + export const ChloridesReportView = () => { const navigate = useNavigate(); const search = Route.useSearch(); + const mapBaseLayer = normalizeMapBaseLayer( + search.mapBase, + BASE_LAYER_NAMES, + DEFAULT_BASE_LAYER, + ); + const mapOverlayNames = normalizeMapOverlayNames( + search.mapOverlays, + OVERLAY_NAMES, + DEFAULT_OVERLAYS, + ); + const mapView = parseMapView(search, { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }); const defaultValues = useMemo(() => { const fallbackFrom = dayjs().startOf("month"); @@ -362,19 +397,39 @@ export const ChloridesReportView = () => { }} > - + + {/* Base Layers */} - - - + + + {/* Wells Cluster Overlay */} - + { ))} + + diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index 4cb55a54..d56ee7f2 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -3,10 +3,12 @@ import { useDebounce } from "use-debounce"; import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; import { Box, Typography } from "@mui/material"; import { useNavigate } from "@tanstack/react-router"; +import { Route } from "@/routes/manage/wells"; import { useGetWellLocations } from "@/service"; import { Well } from "@/interfaces"; import { BoundariesLayer, + MapUrlStateSync, OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, @@ -23,15 +25,57 @@ import "leaflet/dist/leaflet.css"; import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; +import { + DEFAULT_MAP_CENTER, + DEFAULT_MAP_ZOOM, + getMapLayersControlKey, + normalizeMapBaseLayer, + normalizeMapOverlayNames, + parseMapView, +} from "@/utils"; + +const BASE_LAYER_NAMES = ["Satellite", "OpenStreetMap"] as const; +const OVERLAY_NAMES = [ + "Wells", + "Clorides Report Region Guide", + "Transportation", + "Boundaries and Places", +] as const; +const DEFAULT_BASE_LAYER = "OpenStreetMap"; +const DEFAULT_OVERLAYS = ["Clorides Report Region Guide", "Wells"]; + export default function WellSelectionMap({ wellSearchQueryProp, }: { wellSearchQueryProp: string; }) { const navigate = useNavigate(); + const search = Route.useSearch(); const [wellSearchDebounced] = useDebounce(wellSearchQueryProp, 250); const wellQuery = useGetWellLocations(wellSearchDebounced); + const mapBaseLayer = normalizeMapBaseLayer( + search.mapBase, + BASE_LAYER_NAMES, + DEFAULT_BASE_LAYER, + ); + const mapOverlayNames = normalizeMapOverlayNames( + search.mapOverlays, + OVERLAY_NAMES, + DEFAULT_OVERLAYS, + ); + const mapView = parseMapView(search, { + center: DEFAULT_MAP_CENTER, + zoom: DEFAULT_MAP_ZOOM, + }); + + const setSearch = (updater: (prev: typeof search) => typeof search) => { + navigate({ + to: "/manage/wells", + search: (prev) => updater(prev as typeof search), + replace: true, + }); + }; useEffect(() => { if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { @@ -66,19 +110,35 @@ export default function WellSelectionMap({ }} > - + + {/* Base Layers */} - - - + + + {/* Wells Cluster Overlay */} - + ))} - - - + + + {/* Loading first page */} From 3e7c7883f92b263c157b70d6d76643156d855477 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 20:29:44 -0600 Subject: [PATCH 58/91] feat(settings): Add ability to clear saved cache if needed --- frontend/src/service/ApiServiceNew.ts | 4 +- frontend/src/views/Settings.tsx | 67 +++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 2bdb255e..b6e0e4ec 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -112,7 +112,7 @@ function writeMapCache(queryKey: readonly unknown[], data: T) { window.localStorage.setItem(storageKey, JSON.stringify(value)); } -function clearStoredMapCaches() { +export function clearSavedQueryLocalStorage() { if (typeof window === "undefined") return; const keysToRemove: string[] = []; @@ -129,7 +129,7 @@ function clearStoredMapCaches() { function invalidateMapDataCaches( queryClient: ReturnType, ) { - clearStoredMapCaches(); + clearSavedQueryLocalStorage(); MAP_QUERY_ROUTES.forEach((route) => { queryClient.removeQueries(route); queryClient.invalidateQueries(route); diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index c8c27ad6..f03bfdbe 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -39,6 +39,7 @@ import { import { navConfig } from "@/constants"; import { useFetchWithAuth } from "@/hooks"; import { SecurityScope } from "@/interfaces"; +import { clearSavedQueryLocalStorage } from "@/service"; const redirectOptions = { public: navConfig.filter((item) => !item.role), @@ -82,6 +83,7 @@ export const Settings = () => { const [showCurrentPassword, setShowCurrentPassword] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isClearingCachedData, setIsClearingCachedData] = useState(false); const { control: displayNameControl, @@ -323,6 +325,24 @@ export const Settings = () => { avatarMutation.mutate(file); }; + const handleClearCachedData = () => { + setIsClearingCachedData(true); + + try { + clearSavedQueryLocalStorage(); + queryClient.clear(); + enqueueSnackbar("Saved cache cleared.", { + variant: "success", + }); + } catch { + enqueueSnackbar("Failed to clear cached data.", { + variant: "error", + }); + } finally { + setIsClearingCachedData(false); + } + }; + return ( @@ -716,7 +736,9 @@ export const Settings = () => { render={({ field }) => ( { - setShowCurrentPassword((show) => !show) + setShowCurrentPassword( + (show) => !show, + ) } > {showCurrentPassword ? ( @@ -792,7 +816,9 @@ export const Settings = () => { render={({ field }) => ( { - setShowConfirmPassword((show) => !show) + setShowConfirmPassword( + (show) => !show, + ) } > {showConfirmPassword ? ( @@ -840,6 +868,37 @@ export const Settings = () => {
    + + Cached Data + + + + + }> + Clear Saved Cache + + + + + Clears stored app data so pages load fresh information the + next time you open them. + + + + + + + + + From 55a662dd5fdcf2b29515235fcafcfb9e9a23908d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 3 Mar 2026 22:02:28 -0600 Subject: [PATCH 59/91] feat(home): Update home page --- api/main.py | 8 +- api/routes/maintenance.py | 53 ++++- frontend/src/interfaces/HomeSummary.ts | 6 + frontend/src/interfaces/index.ts | 1 + frontend/src/service/ApiServiceNew.ts | 12 ++ frontend/src/views/Home.tsx | 276 +++++++++++++++++++------ 6 files changed, 288 insertions(+), 68 deletions(-) create mode 100644 frontend/src/interfaces/HomeSummary.ts diff --git a/api/main.py b/api/main.py index befc01cd..c6dc898e 100644 --- a/api/main.py +++ b/api/main.py @@ -9,7 +9,10 @@ from api.routes.activities import activity_router, public_activity_router from api.routes.admin import admin_router from api.routes.chlorides import authenticated_chlorides_router, public_chlorides_router -from api.routes.maintenance import maintenance_router +from api.routes.maintenance import ( + authenticated_maintenance_router, + public_maintenance_router, +) from api.routes.meters import authenticated_meter_router, public_meter_router from api.routes.OSE import ose_router from api.routes.parts import part_router @@ -116,7 +119,7 @@ def login_for_access_token( authenticated_router.include_router(activity_router) authenticated_router.include_router(admin_router) authenticated_router.include_router(authenticated_chlorides_router) -authenticated_router.include_router(maintenance_router) +authenticated_router.include_router(authenticated_maintenance_router) authenticated_router.include_router(authenticated_meter_router) authenticated_router.include_router(part_router) authenticated_router.include_router(authenticated_well_measurement_router) @@ -130,5 +133,6 @@ def login_for_access_token( app.include_router(public_meter_router) app.include_router(public_well_router) app.include_router(public_chlorides_router) +app.include_router(public_maintenance_router) app.include_router(public_well_measurement_router) app.include_router(authenticated_router) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 2a4b3449..4adff0cd 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -1,4 +1,5 @@ from fastapi import Depends, APIRouter, Query +from sqlalchemy import func from sqlalchemy.orm import Session from pydantic import BaseModel from typing import List @@ -14,6 +15,8 @@ MeterActivities, ActivityTypeLU, Locations, + workOrders, + workOrderStatusLU, ) from api.session import get_db from api.enums import ScopedUser @@ -32,7 +35,8 @@ autoescape=select_autoescape(["html", "xml"]), ) -maintenance_router = APIRouter() +authenticated_maintenance_router = APIRouter() +public_maintenance_router = APIRouter() class MeterSummary(BaseModel): @@ -55,7 +59,50 @@ class MaintenanceSummaryResponse(BaseModel): table_rows: List[MaintenanceRow] -@maintenance_router.get( +class HomeSummaryResponse(BaseModel): + completed_work_orders: int + repairs_processed: int + reinstallations_processed: int + preventative_maintenance_processed: int + + +@public_maintenance_router.get( + "/maintenance/home_summary", + tags=["Maintenance"], + response_model=HomeSummaryResponse, +) +def get_home_summary(db: Session = Depends(get_db)): + completed_work_orders = ( + db.query(func.count(workOrders.id)) + .join(workOrderStatusLU, workOrderStatusLU.id == workOrders.status_id) + .filter(workOrderStatusLU.name == "Closed") + .scalar() + or 0 + ) + + activity_counts = dict( + db.query(ActivityTypeLU.name, func.count(MeterActivities.id)) + .join(MeterActivities, MeterActivities.activity_type_id == ActivityTypeLU.id) + .filter( + ActivityTypeLU.name.in_( + ["Repair", "Re-install", "Preventative Maintenance"] + ) + ) + .group_by(ActivityTypeLU.name) + .all() + ) + + return { + "completed_work_orders": completed_work_orders, + "repairs_processed": activity_counts.get("Repair", 0), + "reinstallations_processed": activity_counts.get("Re-install", 0), + "preventative_maintenance_processed": activity_counts.get( + "Preventative Maintenance", 0 + ), + } + + +@authenticated_maintenance_router.get( "/maintenance", tags=["Maintenance"], response_model=MaintenanceSummaryResponse, @@ -171,7 +218,7 @@ def get_maintenance_summary( } -@maintenance_router.get( +@authenticated_maintenance_router.get( "/maintenance/pdf", tags=["Maintenance"], dependencies=[Depends(ScopedUser.Read)], diff --git a/frontend/src/interfaces/HomeSummary.ts b/frontend/src/interfaces/HomeSummary.ts new file mode 100644 index 00000000..d32d133f --- /dev/null +++ b/frontend/src/interfaces/HomeSummary.ts @@ -0,0 +1,6 @@ +export interface HomeSummary { + completed_work_orders: number; + repairs_processed: number; + reinstallations_processed: number; + preventative_maintenance_processed: number; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 21fce508..eb72bd4b 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -6,6 +6,7 @@ export * from "./BaseWell"; export * from "./CreateUser"; export * from "./DeviceAttributes"; export * from "./DevicePayload"; +export * from "./HomeSummary"; export * from "./IncreaseQuantityPayload"; export * from "./LandOwner"; export * from "./Location"; diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index b6e0e4ec..c631b587 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -10,6 +10,7 @@ import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { ActivityTypeLU, + HomeSummary, MeterListDTO, MeterListQueryParams, MeterTypeLU, @@ -360,6 +361,17 @@ export function useGetMeterTypeList() { ); } +export function useGetHomeSummary() { + const route = "maintenance/home_summary"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery([route], () => + GETFetch(route, null, authHeader(), signOut, navigate), + ); +} + export function useGetMeterRegisterList() { const route = "meter_registers"; const authHeader = useAuthHeader(); diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx index a1e28c33..564940c3 100644 --- a/frontend/src/views/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -1,28 +1,68 @@ import { - Grid, + Box, Card, CardContent, CardMedia, + Grid, List, ListItem, ListItemText, + Skeleton, Stack, Typography, } from "@mui/material"; +import HomeIcon from "@mui/icons-material/Home"; +import AssignmentTurnedInOutlinedIcon from "@mui/icons-material/AssignmentTurnedInOutlined"; +import AutorenewOutlinedIcon from "@mui/icons-material/AutorenewOutlined"; +import BuildCircleOutlinedIcon from "@mui/icons-material/BuildCircleOutlined"; +import FactCheckOutlinedIcon from "@mui/icons-material/FactCheckOutlined"; +import { BackgroundBox, CustomCardHeader } from "@/components"; import pvacd_logo from "@/img/pvacd_logo.png"; import meter_field from "@/img/meter_field.jpg"; import meter_storage from "@/img/meter_storage.jpg"; -import HomeIcon from "@mui/icons-material/Home"; -import { CustomCardHeader, BackgroundBox } from "@/components"; +import { useGetHomeSummary } from "@/service"; + +const formatStat = (value?: number) => + typeof value === "number" ? value.toLocaleString("en-US") : "0"; + +const statCards = [ + { + key: "completed_work_orders", + label: "Work Orders Completed", + icon: AssignmentTurnedInOutlinedIcon, + color: "#1f4d3a", + }, + { + key: "repairs_processed", + label: "Repairs", + icon: BuildCircleOutlinedIcon, + color: "#7c3f00", + }, + { + key: "reinstallations_processed", + label: "Meter Reinstallations", + icon: AutorenewOutlinedIcon, + color: "#0f4c81", + }, + { + key: "preventative_maintenance_processed", + label: "Preventative Maintenance Visits", + icon: FactCheckOutlinedIcon, + color: "#6a1b3f", + }, +] as const; export const Home = () => { + const summaryQuery = useGetHomeSummary(); + const versionHistory = [ - "V0.2.0 - Parts-used report functional with PDF download", + "V0.2.1 - ", + "V0.2.0 - Add report functional with PDF download", "V0.1.52 - Deploy chlorides for admin testing", "V0.1.51 - Improved monitoring well page", - "V0.1.50 - Fixed wells map bug and update register if part used", - "V0.1.49 - Added outside recorder wells to monitoring page", - "V0.1.48 - Changed well owner to be meter water users", + "V0.1.50 - Fix wells map bug and update register if part used", + "V0.1.49 - Add outside recorder wells to monitoring page", + "V0.1.48 - Change well owner to be meter water users", "V0.1.47 - Add TRSS grids to meter map and fixed meter register save bug", "V0.1.46 - Change how data is displayed in Wells table", "V0.1.45 - Color code meter markers on map by last PM", @@ -34,74 +74,184 @@ export const Home = () => { return ( - + - - - - - - PVACD Meter Manager Info - - Version History - - {versionHistory.map((version) => ( - - - - ))} - - - - - + + + - + > + + A complete system for managing meters, wells, and field + operations. + + + + + + + Since launch + + + {statCards.map((card) => { + const Icon = card.icon; + const value = summaryQuery.data?.[card.key]; + + return ( + + + + + + + + + {summaryQuery.isLoading ? ( + + ) : ( + formatStat(value) + )} + + + {card.label} + + + + + + ); + })} + + + + + + + + + + + + + + + + Release Notes + + + Version History + + + {versionHistory.map((version) => ( + + + + ))} + + + + From c67983aa0b1f62e94a4c43742cac8a1a78d910a5 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 9 Mar 2026 22:09:12 -0500 Subject: [PATCH 60/91] refactor(routes): Refactor serach params --- frontend/src/App.tsx | 6 +- .../RHControlled/ControlledSelect.tsx | 21 ++-- frontend/src/components/ReportsNavItem.tsx | 2 +- frontend/src/components/Topbar.tsx | 8 +- frontend/src/router.tsx | 4 + frontend/src/routes/__root.tsx | 23 ++-- frontend/src/routes/activities.tsx | 41 +++---- frontend/src/routes/chlorides.tsx | 21 +++- frontend/src/routes/manage/backups.tsx | 13 ++- frontend/src/routes/manage/meters.tsx | 77 +++++-------- .../src/routes/manage/parts/$id/history.tsx | 84 ++++++++++---- frontend/src/routes/manage/parts/index.tsx | 51 ++++----- frontend/src/routes/manage/users.tsx | 43 +++++--- frontend/src/routes/manage/wells.tsx | 69 ++++-------- frontend/src/routes/monitoringwells.tsx | 15 ++- frontend/src/routes/reports/chlorides.tsx | 36 +++--- frontend/src/routes/reports/maintenance.tsx | 76 +++++-------- .../src/routes/reports/monitoringwells.tsx | 95 ++++++---------- frontend/src/routes/reports/partsused.tsx | 67 ++++------- frontend/src/routes/workorders.tsx | 65 ++++------- frontend/src/sidenav.tsx | 2 +- frontend/src/utils/RouteSearch.ts | 104 ++++++++++++++++++ frontend/src/utils/index.ts | 1 + frontend/src/views/Parts/PartsHistory.tsx | 72 +++++++----- frontend/src/views/Parts/PartsTable.tsx | 8 ++ 25 files changed, 527 insertions(+), 477 deletions(-) create mode 100644 frontend/src/utils/RouteSearch.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de4e65ec..d3eebc19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,9 +7,9 @@ import { SnackbarProvider } from "notistack"; import { router } from "./router"; import { ErrorMessageProvider } from "./contexts/ErrorMessageContext"; -export const App = () => { - const queryClient = new QueryClient(); +const queryClient = new QueryClient(); +export const App = () => { return ( @@ -24,7 +24,7 @@ export const App = () => { cookieSecure={window.location.protocol === "https:"} > - + diff --git a/frontend/src/components/RHControlled/ControlledSelect.tsx b/frontend/src/components/RHControlled/ControlledSelect.tsx index 696cf9f8..70bae13a 100644 --- a/frontend/src/components/RHControlled/ControlledSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledSelect.tsx @@ -20,6 +20,9 @@ export function ControlledSelect({ control={control} render={({ field }) => { const isMultiple = multiple; + const options = Array.isArray(childProps.options) + ? childProps.options + : []; // Normalize value for multi/single mode const value = isMultiple @@ -28,13 +31,15 @@ export function ControlledSelect({ const handleChange = (event: any) => { if (isMultiple) { - const selectedIds = event.target.value; - const selectedOptions = childProps.options.filter((opt: any) => + const selectedIds = Array.isArray(event.target.value) + ? event.target.value + : []; + const selectedOptions = options.filter((opt: any) => selectedIds.includes(opt.id), ); field.onChange(selectedOptions); } else { - const selectedOption = childProps.options.find( + const selectedOption = options.find( (opt: any) => opt.id === event.target.value, ); field.onChange(selectedOption); @@ -57,18 +62,18 @@ export function ControlledSelect({ label={childProps.label} renderValue={(selected: any) => isMultiple - ? childProps.options + ? options .filter((opt: any) => selected.includes(opt.id)) .map((opt: any) => childProps.getOptionLabel(opt)) .join(", ") : childProps.getOptionLabel( - childProps.options.find( + options.find( (opt: any) => opt.id === selected, ) ?? {}, ) } > - {childProps.options.map((option: any) => ( + {options.map((option: any) => ( {childProps.getOptionLabel(option)} @@ -93,6 +98,8 @@ export function ControlledSelectNonObject({ name, ...childProps }: any) { + const options = Array.isArray(childProps.options) ? childProps.options : []; + return ( - {childProps.options.map((option: any) => ( + {options.map((option: any) => ( {childProps.getOptionLabel(option)} diff --git a/frontend/src/components/ReportsNavItem.tsx b/frontend/src/components/ReportsNavItem.tsx index 893470ba..7b899b81 100644 --- a/frontend/src/components/ReportsNavItem.tsx +++ b/frontend/src/components/ReportsNavItem.tsx @@ -39,7 +39,7 @@ export function ReportsNavItem({ } e.stopPropagation(); setOpen(false); - navigate({ to: "/reports" }); + navigate({ to: "/reports", search: {} }); }; return ( diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index b80d4e3b..20f73fc2 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -49,7 +49,7 @@ export const Topbar = ({ }; const fullSignOut = () => { - navigate({ to: "/" }); + navigate({ to: "/", search: {} }); localStorage.removeItem("loggedIn"); signOut(); }; @@ -94,7 +94,7 @@ export const Topbar = ({ xl: "1.625remrem", }, }} - onClick={() => navigate({ to: "/" })} + onClick={() => navigate({ to: "/", search: {} })} > Meter Manager @@ -138,7 +138,7 @@ export const Topbar = ({ { - navigate({ to: "/settings" }); + navigate({ to: "/settings", search: {} }); handleMenuClose(); }} > @@ -164,7 +164,7 @@ export const Topbar = ({ ) : ( )} diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index 3d4e0c1c..691c0c42 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -1,11 +1,4 @@ -import { - Avatar, - Button, - ButtonProps, - useTheme, - useMediaQuery, - IconButton, -} from "@mui/material"; +import { Avatar, ButtonProps, useTheme, IconButton } from "@mui/material"; import { Badge, Engineering, Face } from "@mui/icons-material"; import { getRoleColor } from "@/utils"; @@ -20,7 +13,6 @@ export const TopbarUserButton = ({ src?: string; } & ButtonProps) => { const theme = useTheme(); - const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); const buttonColor = getRoleColor(role); const primary = theme.palette.primary; @@ -49,15 +41,15 @@ export const TopbarUserButton = ({ OSE: warning.contrastText, }; - return isSmallScreen ? ( + return ( - ) : ( - ); }; diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index c5ae19bc..2147da14 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -19,6 +19,7 @@ import { useEffect, useRef, } from "react"; +import { BgColor } from "@/constants"; export const DESKTOP_MIN_WIDTH = 240; export const DESKTOP_MAX_WIDTH = 420; @@ -36,8 +37,7 @@ const panelSurfaceSx: SxProps = { overflow: "hidden", borderRight: "1px solid", borderColor: "divider", - background: - "linear-gradient(180deg, rgba(248,250,252,0.98) 0%, rgba(255,255,255,0.96) 100%)", + backgroundColor: BgColor, boxShadow: "0 20px 45px rgba(15, 23, 42, 0.08)", backdropFilter: "blur(12px)", }; @@ -248,8 +248,7 @@ export function SidebarHeader({ children, sx, ...props }: BoxProps) { py: 1.5, borderBottom: "1px solid", borderColor: "divider", - background: - "linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.92) 100%)", + backgroundColor: BgColor, ...sx, }} > diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index cb86f736..65b29d29 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -12,6 +12,8 @@ import { import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; +export const BgColor = "#F8FAFC"; + type NavItem = { path: string; label: string; diff --git a/frontend/src/routes/chlorides.tsx b/frontend/src/routes/chlorides.tsx index 0aa4e3c7..2836486a 100644 --- a/frontend/src/routes/chlorides.tsx +++ b/frontend/src/routes/chlorides.tsx @@ -35,6 +35,7 @@ import { routeSearchHydrator, } from "@/utils"; import { Table, Plot } from "@/views/Chlorides"; +import { BgColor } from "@/constants"; const searchSchema = z.object({ regionId: optionalPositiveInt.catch(undefined).default(undefined), @@ -232,7 +233,7 @@ function Chlorides() { return ( - + {error && ( diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index a50410e3..5117162d 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -5,6 +5,7 @@ import { Box, ButtonBase, Collapse, + Divider, Typography, useMediaQuery, useTheme, @@ -291,12 +292,12 @@ export default function Sidenav({ {visibleCollapsedItems @@ -367,16 +368,8 @@ export default function Sidenav({ /> ))} - - {hasReadScope ? ( - - - - - Pages - - + {hasReadScope ? ( {navConfig .filter( @@ -417,17 +410,9 @@ export default function Sidenav({ - - ) : null} + ) : null} - {hasAdminScope ? ( - - - - - Pages - - + {hasAdminScope ? ( {navConfig .filter((item) => item.role === "Admin") @@ -441,8 +426,8 @@ export default function Sidenav({ /> ))} - - ) : null} + ) : null} + )} From da228b99c50a576a400299082c4f9dee1b3e09e0 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 11 Mar 2026 18:13:15 -0500 Subject: [PATCH 66/91] feat(Home): Rm version history part & replaced with links to public data --- frontend/src/views/Home.tsx | 144 +++++++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 35 deletions(-) diff --git a/frontend/src/views/Home.tsx b/frontend/src/views/Home.tsx index 564940c3..63e745a6 100644 --- a/frontend/src/views/Home.tsx +++ b/frontend/src/views/Home.tsx @@ -1,21 +1,23 @@ import { Box, + Button, Card, CardContent, CardMedia, Grid, - List, - ListItem, - ListItemText, Skeleton, Stack, Typography, } from "@mui/material"; +import { Link } from "@tanstack/react-router"; import HomeIcon from "@mui/icons-material/Home"; +import ArrowOutwardIcon from "@mui/icons-material/ArrowOutward"; import AssignmentTurnedInOutlinedIcon from "@mui/icons-material/AssignmentTurnedInOutlined"; import AutorenewOutlinedIcon from "@mui/icons-material/AutorenewOutlined"; import BuildCircleOutlinedIcon from "@mui/icons-material/BuildCircleOutlined"; import FactCheckOutlinedIcon from "@mui/icons-material/FactCheckOutlined"; +import MonitorHeartIcon from "@mui/icons-material/MonitorHeart"; +import ScienceIcon from "@mui/icons-material/Science"; import { BackgroundBox, CustomCardHeader } from "@/components"; import pvacd_logo from "@/img/pvacd_logo.png"; import meter_field from "@/img/meter_field.jpg"; @@ -52,26 +54,30 @@ const statCards = [ }, ] as const; +const publicLinks = [ + { + title: "Chlorides", + description: + "Browse chloride measurements by region and review recent sampling data.", + to: "/chlorides", + icon: ScienceIcon, + accent: + "linear-gradient(135deg, rgba(16, 76, 129, 0.12) 0%, rgba(24, 197, 244, 0.22) 100%)", + }, + { + title: "Monitoring Wells", + description: + "Explore monitoring well readings, trends, and public well data in one place.", + to: "/monitoringwells", + icon: MonitorHeartIcon, + accent: + "linear-gradient(135deg, rgba(31, 77, 58, 0.12) 0%, rgba(105, 181, 93, 0.22) 100%)", + }, +] as const; + export const Home = () => { const summaryQuery = useGetHomeSummary(); - const versionHistory = [ - "V0.2.1 - ", - "V0.2.0 - Add report functional with PDF download", - "V0.1.52 - Deploy chlorides for admin testing", - "V0.1.51 - Improved monitoring well page", - "V0.1.50 - Fix wells map bug and update register if part used", - "V0.1.49 - Add outside recorder wells to monitoring page", - "V0.1.48 - Change well owner to be meter water users", - "V0.1.47 - Add TRSS grids to meter map and fixed meter register save bug", - "V0.1.46 - Change how data is displayed in Wells table", - "V0.1.45 - Color code meter markers on map by last PM", - "V0.1.44 - Fix bug in continuous monitoring well data and added data to OSE endpoint", - 'V0.1.43 - Fix navigation from work orders to activity, add OSE endpoint for "data issues"', - "V0.1.42 - Fix pagination, add 'uninstall and hold'", - "V0.1.41 - Add UI for water source on wells and some other minor changes", - ]; - return ( { }} > - + - Release Notes + Public Data - - Version History + + Access public measurements and well monitoring data. - - {versionHistory.map((version) => ( - - - - ))} - + + {publicLinks.map((item) => { + const Icon = item.icon; + + return ( + + + + + + + + + {item.title} + + + + {item.description} + + + + + + + + ); + })} + From ca51cab8ad7747c9e426a0a57ea75ecc436311f6 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 11 Mar 2026 18:44:08 -0500 Subject: [PATCH 67/91] feat(AppLayout): Rm sidebar for users not logged in --- frontend/src/AppLayout.tsx | 39 ++++++++++++++++++------------ frontend/src/components/Topbar.tsx | 2 +- frontend/src/sidenav.tsx | 2 -- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 3ab1b723..ad1b9a75 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -3,6 +3,7 @@ import { Box, useMediaQuery, useTheme } from "@mui/material"; import { Topbar } from "@/components"; import { DESKTOP_COLLAPSED_WIDTH, SidebarInset } from "@/components/ui/sidebar"; import Sidenav from "./sidenav"; +import { useAuthUser } from "react-auth-kit"; const defaultSidebarWidth = 280; const sidebarOpenStorageKey = "wmdb.sidebar.open"; @@ -34,6 +35,9 @@ export const AppLayout = ({ children }: { children: JSX.Element }) => { const isDesktop = useMediaQuery(theme.breakpoints.up("md")); const [drawerOpen, setDrawerOpen] = useState(readStoredSidebarOpen); const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth); + const authUser = useAuthUser(); + const isLoggedIn = !!authUser(); + const shouldShowDesktopSidebar = isDesktop && isLoggedIn; useEffect(() => { if (!isDesktop) { @@ -54,7 +58,7 @@ export const AppLayout = ({ children }: { children: JSX.Element }) => { window.localStorage.setItem(sidebarWidthStorageKey, String(sidebarWidth)); }, [sidebarWidth]); - const effectiveSidebarWidth = isDesktop + const effectiveSidebarWidth = shouldShowDesktopSidebar ? drawerOpen ? sidebarWidth : DESKTOP_COLLAPSED_WIDTH @@ -74,25 +78,30 @@ export const AppLayout = ({ children }: { children: JSX.Element }) => { sidebarWidth={sidebarWidth} onMenuClick={() => setDrawerOpen((prev) => !prev)} /> - setDrawerOpen(false)} - onOpen={() => setDrawerOpen(true)} - onWidthChange={(width) => { - setSidebarWidth(width); - if (!drawerOpen) { - setDrawerOpen(true); - } - }} - /> + {shouldShowDesktopSidebar ? ( + setDrawerOpen(false)} + onOpen={() => setDrawerOpen(true)} + onWidthChange={(width) => { + setSidebarWidth(width); + if (!drawerOpen) { + setDrawerOpen(true); + } + }} + /> + ) : null} + diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 16617447..5a0d49bf 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -46,7 +46,7 @@ export const Topbar = ({ const role: string = authUser()?.user_role?.name; const isLoggedIn = !!authUser(); - const effectiveSidebarWidth = isDesktop + const effectiveSidebarWidth = isDesktop && isLoggedIn ? open ? sidebarWidth : DESKTOP_COLLAPSED_WIDTH diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index 5117162d..eafc826c 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -5,7 +5,6 @@ import { Box, ButtonBase, Collapse, - Divider, Typography, useMediaQuery, useTheme, @@ -18,7 +17,6 @@ import { ExpandMore, TableView, } from "@mui/icons-material"; -import { RoleChip } from "@/components"; import { useIsActiveRoute } from "@/hooks"; import { useGetWorkOrders } from "@/service"; import { WorkOrderStatus } from "@/enums"; From dae72862e5c5b90fd8a7c49ae9077bf8e9255b89 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 11 Mar 2026 18:53:08 -0500 Subject: [PATCH 68/91] fix(AppLayout): Patch broken show sidebar logic on mobile --- frontend/src/AppLayout.tsx | 3 ++- frontend/src/components/Topbar.tsx | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index ad1b9a75..67ec5ab9 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -37,6 +37,7 @@ export const AppLayout = ({ children }: { children: JSX.Element }) => { const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth); const authUser = useAuthUser(); const isLoggedIn = !!authUser(); + const shouldRenderSidebar = !isDesktop || isLoggedIn; const shouldShowDesktopSidebar = isDesktop && isLoggedIn; useEffect(() => { @@ -78,7 +79,7 @@ export const AppLayout = ({ children }: { children: JSX.Element }) => { sidebarWidth={sidebarWidth} onMenuClick={() => setDrawerOpen((prev) => !prev)} /> - {shouldShowDesktopSidebar ? ( + {shouldRenderSidebar ? ( ) => { setAnchorEl(event.currentTarget); From d2506ff2a48018da1625f18d549b16c640c5970f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 11 Mar 2026 19:08:13 -0500 Subject: [PATCH 69/91] feat(Topbar): Add way to navigate for nonloggedin users on desktop --- frontend/src/components/Topbar.tsx | 148 +++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index a31c9c14..24d11e32 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -15,7 +15,15 @@ import { } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import CloseIcon from "@mui/icons-material/Close"; -import { Logout, Settings } from "@mui/icons-material"; +import { + ExpandMore, + Home, + Logout, + MonitorHeart, + Public, + Science, + Settings, +} from "@mui/icons-material"; import { useNavigate } from "@tanstack/react-router"; import { useAuthUser, useSignOut } from "react-auth-kit"; import { RoleChip, TopbarUserButton } from "./index"; @@ -24,6 +32,7 @@ import { TOPBAR_HEIGHT, } from "@/components/ui/sidebar"; import { BgColor } from "@/constants"; +import { useIsActiveRoute } from "@/hooks"; export const Topbar = ({ open, @@ -41,11 +50,19 @@ export const Topbar = ({ const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); + const isHomeActive = useIsActiveRoute("/"); + const isChloridesActive = useIsActiveRoute("/chlorides"); + const isMonitoringWellsActive = useIsActiveRoute("/monitoringwells"); - const [anchorEl, setAnchorEl] = useState(null); + const [userMenuAnchorEl, setUserMenuAnchorEl] = useState( + null, + ); + const [publicMenuAnchorEl, setPublicMenuAnchorEl] = + useState(null); const role: string = authUser()?.user_role?.name; const isLoggedIn = !!authUser(); + const isPublicDataActive = isChloridesActive || isMonitoringWellsActive; const effectiveSidebarWidth = isDesktop && isLoggedIn ? open @@ -54,11 +71,28 @@ export const Topbar = ({ : 0; const handleMenuOpen = (event: MouseEvent) => { - setAnchorEl(event.currentTarget); + setUserMenuAnchorEl(event.currentTarget); }; const handleMenuClose = () => { - setAnchorEl(null); + setUserMenuAnchorEl(null); + }; + + const handlePublicMenuOpen = (event: MouseEvent) => { + setPublicMenuAnchorEl(event.currentTarget); + }; + + const handlePublicMenuClose = () => { + setPublicMenuAnchorEl(null); + }; + + const handlePublicMenuToggle = (event: MouseEvent) => { + if (publicMenuAnchorEl) { + handlePublicMenuClose(); + return; + } + + handlePublicMenuOpen(event); }; const fullSignOut = () => { @@ -135,6 +169,108 @@ export const Topbar = ({ + {isDesktop && !isLoggedIn ? ( + + + + + { + navigate({ to: "/chlorides", search: {} }); + handlePublicMenuClose(); + }} + sx={{ + color: isChloridesActive ? "darkblue" : "text.primary", + "& .MuiListItemIcon-root": { + color: isChloridesActive ? "darkblue" : "action.active", + }, + }} + > + + + + Chlorides + + { + navigate({ to: "/monitoringwells", search: {} }); + handlePublicMenuClose(); + }} + sx={{ + color: isMonitoringWellsActive ? "darkblue" : "text.primary", + "& .MuiListItemIcon-root": { + color: isMonitoringWellsActive + ? "darkblue" + : "action.active", + }, + }} + > + + + + Monitoring Wells + + + + ) : null} + {isLoggedIn ? ( Date: Wed, 11 Mar 2026 19:18:45 -0500 Subject: [PATCH 70/91] chore(package): Resync file --- frontend/package-lock.json | 3449 ++++++++++++++++-------------------- frontend/package.json | 2 +- 2 files changed, 1539 insertions(+), 1912 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a6e3f70e..a05b7ff4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -123,16 +123,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -166,16 +156,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -316,9 +296,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", - "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -399,10 +379,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.0.tgz", + "integrity": "sha512-VfTOSc6XRdRGjdkTSC7AHmV1HdGlmUQ4/6TCb570uLsPFyFkG7nCVQYjbWZun3BilIQsyIuLSSWxrZWR+XH/rg==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -411,10 +391,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.4.0.tgz", + "integrity": "sha512-zlpEF4KJhfl96j0M6wPmgaUVz20VKYZziIcIvf9pqGrvsTl1kDnoBtpmAROuU3e7FeCqDhk4qSQvorusW+L62g==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -423,10 +403,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.0.tgz", + "integrity": "sha512-zqpXcl+RHza3DeN3WcqtXMkQanI6wHUg/plJFb+uqI4KeXkJ6NBVsHNH7A4EImY/XZ4H3nw1g30io//ji5bxkw==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -435,10 +415,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.4.0.tgz", + "integrity": "sha512-tGtmnBfjgdElgKouzEuIdJXQ0makePI1rZnVLW5hJxA6A3xWEAQOIHCqTA0UDBHjM/uJP5lspxUIJrJHU76/8Q==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -447,10 +427,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.0.tgz", + "integrity": "sha512-d43CWzswbwed4q1RZFxt1qlhQfqzPGZVwGe0/+PZIr1B4U8y3/AqT7y1TptTdk6lL65XNhJKM30cxn72+x5fTA==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -459,10 +439,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.4.0.tgz", + "integrity": "sha512-xUJGFriKkBEs4dRe8rZ7fqT49x0JgOVwpl1A5hYXYI6BPZqyX4wfCPPynyPtYyZDWy+nuCWxFgc2fZCBV/hW7g==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -471,10 +451,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.0.tgz", + "integrity": "sha512-LPXCc11Yw/p54OYNjyyiNoCdqXybuAWJRxkcpThx9S/TKouuwnEroj5PL3b1+unreCHtMDzkcO9dia7mqX9DYQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -483,10 +463,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.0.tgz", + "integrity": "sha512-vuFC5HRfzla7YH2s02CBrxBr+ninbZu9PtO3a72JoO8Da02/POI7RF3WjjlzfRG4+i5NHyn77gKsl2cy8rTTXA==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -495,10 +475,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.4.0.tgz", + "integrity": "sha512-ACIM6Cu0es4TdMA0jHUlKtWh50AZS0HJ5ykeBueZpPhMMGbjkRV90Sit/4+I2ghTOZ6Veug+UjEKz4VUbkfKwA==", + "license": "See LICENSE file", "engines": { "node": ">=18.0.0" }, @@ -507,41 +487,42 @@ } }, "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" + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.4.0.tgz", + "integrity": "sha512-OVMKwwS+npvbkJeOSIhtciOemUx//o1TpgwoOwGMffywsalL7+Mz9he/i6kT3xxi4mVkFDR46rtz4J/VlexXnQ==", + "license": "MIT", + "dependencies": { + "@dicebear/adventurer": "9.4.0", + "@dicebear/adventurer-neutral": "9.4.0", + "@dicebear/avataaars": "9.4.0", + "@dicebear/avataaars-neutral": "9.4.0", + "@dicebear/big-ears": "9.4.0", + "@dicebear/big-ears-neutral": "9.4.0", + "@dicebear/big-smile": "9.4.0", + "@dicebear/bottts": "9.4.0", + "@dicebear/bottts-neutral": "9.4.0", + "@dicebear/croodles": "9.4.0", + "@dicebear/croodles-neutral": "9.4.0", + "@dicebear/dylan": "9.4.0", + "@dicebear/fun-emoji": "9.4.0", + "@dicebear/glass": "9.4.0", + "@dicebear/icons": "9.4.0", + "@dicebear/identicon": "9.4.0", + "@dicebear/initials": "9.4.0", + "@dicebear/lorelei": "9.4.0", + "@dicebear/lorelei-neutral": "9.4.0", + "@dicebear/micah": "9.4.0", + "@dicebear/miniavs": "9.4.0", + "@dicebear/notionists": "9.4.0", + "@dicebear/notionists-neutral": "9.4.0", + "@dicebear/open-peeps": "9.4.0", + "@dicebear/personas": "9.4.0", + "@dicebear/pixel-art": "9.4.0", + "@dicebear/pixel-art-neutral": "9.4.0", + "@dicebear/rings": "9.4.0", + "@dicebear/shapes": "9.4.0", + "@dicebear/thumbs": "9.4.0", + "@dicebear/toon-head": "9.4.0" }, "engines": { "node": ">=18.0.0" @@ -551,22 +532,22 @@ } }, "node_modules/@dicebear/core": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz", - "integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.0.tgz", + "integrity": "sha512-uoAG5mPBX+kQTtVerWUoH5e7rezG+DV/vJ5icd/kGooGyylH0nuJIlA6todkKGQv+/b0QNo+EzNF6Nc4UTE3wQ==", "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.11" + "@types/json-schema": "^7.0.15" }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.0.tgz", + "integrity": "sha512-tC68VGu0XOtDd4aOORvchtRy1EMphuTWCl/vDIlS9zuKJJxIJCh0r7mREn/Azds07Hdg1R1Mr8j85tdVonEpgQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -575,10 +556,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.4.0.tgz", + "integrity": "sha512-kRFE46B+WfGU4yDaD0ESSvt9A6CBtxuR7sGcFJ4YhK4T/O+tnP+iqRuQ3+ob1oNdEW3oQaD9aBioi3hBfbrrBA==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -587,10 +568,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.4.0.tgz", + "integrity": "sha512-1HxZyVmPf5ElERs4NqDtWHw6OBDae5v6t4zspCXRzMH/H0onwlbx3uAZDNGFdPgah8bSV3MhAzhggTCNcWtMxw==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -599,10 +580,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.0.tgz", + "integrity": "sha512-dDOw30RfCNfqqeXny4eQLgyMEXfZ0Y5Gz+rSPCuXGw735rCF+Wehyy4tzl2icCkXhWK9attlAY9anjV45k/2aQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -611,9 +592,9 @@ } }, "node_modules/@dicebear/glass": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.4.tgz", - "integrity": "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.4.0.tgz", + "integrity": "sha512-piYKjXTPiTmdgkEW8OEAQNTbcAwtI0+iR2ODfKWnWBy8lM+rnY4TmBi3RgMFJXLFqjPgu38SXTsd2bWAfVa4MQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -623,9 +604,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.4.0.tgz", + "integrity": "sha512-iwA4uM8E9B9kCEMJfxvgfDGje3h2ZE84SDuvJjjCWWZP/LJ5YX50QcRrfknRffD439DXJsKdXy9ku4OB5G7TkQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -635,9 +616,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.0.tgz", + "integrity": "sha512-6X5z7oHeGPuw9i7DaHQAQdHGAu9KYUgTZx8lWLJH/wutzCkygpNm7P0Q1FaP8zmdLkhj4AknQOoZ5AW0kaW4Lg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -647,9 +628,9 @@ } }, "node_modules/@dicebear/initials": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz", - "integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.0.tgz", + "integrity": "sha512-Qt0jDQKyo63HD8o3mXgb+PzM0L01BWpURtrEETZEGgES+C3Qz5fQPbVDdkKSNXn5yyjv6LbdniJJUjTxDmQAQw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -659,9 +640,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.0.tgz", + "integrity": "sha512-P91tqHckYj+IPw906F3SQwKvIMClJFwfYb4mvJGYoy/PyQVcRdT7ziKbYrG70bHKgdSEQSAarOdLH4EDLX4IpA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -671,9 +652,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.4.0.tgz", + "integrity": "sha512-3ceiazxgIN/9p6Ndg6X76N+RH61PSg0+717YiAZ5WN/epia/UUYzsZ5RrLyjrdq30SRNeHawp58qbAkOYMWD7g==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -683,10 +664,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.0.tgz", + "integrity": "sha512-fMtENHrq7ZFNt+HpZTP0yr06dw76ur6SCjMK1eQBX6fwgtJ8HkHa/4TjhpjvQTarJJPs6FDPtGkHcYKCehBUNw==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -695,10 +676,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.4.0.tgz", + "integrity": "sha512-Gh4C8xF3vRM+FkEtfiYWLaRYCZP1Bzdg/gjLqvn/rJ9TCo645KksPcpABShZv7BPbOCkr17lhSrfBmlRjQnzkQ==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -707,9 +688,9 @@ } }, "node_modules/@dicebear/notionists": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.4.tgz", - "integrity": "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.0.tgz", + "integrity": "sha512-MgZuW5of3b3cjLFi+D+iONZ3t/t9TZHYUyBXDmRxgeQW+l6td3n8Mjg8eA81jbzVC2RNyxKCOjZu6EyjyX88tA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -719,9 +700,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.4.0.tgz", + "integrity": "sha512-wzg/NLcIzSM2O8IXcEFucYLJypS7I3VKmBsn4ShdM1qQ5nNlA8Ig3e9GKkfxRS2K+xTNDHyXuXNB88pj5Uzmig==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -731,9 +712,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.4.0.tgz", + "integrity": "sha512-IxbfUWoYEUFdqYqz0iLYODbShV3GWx0t2Afq4pw6KTSewusjMIuYlvyK4z8cFkc2Ai/7VXRBLvQd+YA8KRMpIw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -743,10 +724,10 @@ } }, "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", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.4.0.tgz", + "integrity": "sha512-CjmIiOEwEmQeccIF0U7uzzBLOn9PWNFz87vAAiToWVzA4pVuzHgA+OiKzC6n91lZfRy76bGL1JtR8/ZppCN0YA==", + "license": "(MIT AND CC-BY-4.0)", "engines": { "node": ">=18.0.0" }, @@ -755,9 +736,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.0.tgz", + "integrity": "sha512-oQm9pGOaYCgfnxtzNY8xaJa3ZBH12xd7p4UT35ghvtRgk394uCnmz/bg71tnj2ynwVmZ4s5gBoWlUymnTvvCOw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -767,9 +748,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.4.0.tgz", + "integrity": "sha512-OGYFbow6Hu345OObR0dPOAImuGP5vFqNkzkfkEPF4DPbLnCa3RjpeoCkyB+/Gvz7qAtyRR8W57Tfj6PQVRLLXg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -779,9 +760,9 @@ } }, "node_modules/@dicebear/rings": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.4.tgz", - "integrity": "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.0.tgz", + "integrity": "sha512-lEhPwUd/uZFLAWM296/aNSGaCyT9NaTXm6V3izFtD8pywceze+sV3s46uLKpvCKUEcI4ia5iMERV35EH5P2ixg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -791,9 +772,9 @@ } }, "node_modules/@dicebear/shapes": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.4.tgz", - "integrity": "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.0.tgz", + "integrity": "sha512-WTH1j6xqwdzBYiTPsCECqlB7kYC0TIbdlg49jEZJp9qP0tguVMH+M7GmWY5TO2chTRmYjJREmgvZWPgmE1Sd9Q==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -803,9 +784,9 @@ } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.0.tgz", + "integrity": "sha512-eppbqo+3CvlDF4cwWNBsdNmtXHkVaj5AvM9KimVBWdp0S98foTTekCaQCBCmDfATywVXEGk+GaThTZdYgIE/0Q==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -814,6 +795,18 @@ "@dicebear/core": "^9.0.0" } }, + "node_modules/@dicebear/toon-head": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@dicebear/toon-head/-/toon-head-9.4.0.tgz", + "integrity": "sha512-3u4ghFUFhnV1LYAfbltihOnASCk4qeWYLjg8B9U6drovrxY4yfX13vqNzQePormLvehXKpm9+gKbmy4kMt2w+g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=16.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", @@ -853,9 +846,9 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -911,9 +904,9 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -961,9 +954,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -978,9 +971,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -995,9 +988,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -1012,9 +1005,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -1029,9 +1022,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -1046,9 +1039,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -1063,9 +1056,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -1080,9 +1073,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -1097,9 +1090,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -1114,9 +1107,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -1131,9 +1124,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -1148,9 +1141,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -1165,9 +1158,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -1182,9 +1175,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -1199,9 +1192,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -1216,9 +1209,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -1233,9 +1226,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -1250,9 +1243,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -1267,9 +1260,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -1284,9 +1277,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -1301,9 +1294,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -1335,9 +1328,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -1352,9 +1345,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -1369,9 +1362,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -1386,9 +1379,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -1403,9 +1396,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1435,9 +1428,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -1445,34 +1438,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1483,20 +1479,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1520,19 +1516,22 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1540,13 +1539,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -1554,31 +1553,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -1586,9 +1585,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@hookform/resolvers": { @@ -1611,33 +1610,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1653,9 +1638,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1741,9 +1726,9 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", "peer": true, "dependencies": { @@ -1752,9 +1737,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1875,7 +1860,7 @@ "version": "5.0.0-dev.20240529-082515-213b5e33ab", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-dev.20240529-082515-213b5e33ab.tgz", "integrity": "sha512-3ic6fc6BHstgM+MGqJEVx3zt9g5THxVXm3VVFUfdeplPqAWWgW2QoKfZDLT10s+pi+MAkpgEBP0kgRidf81Rsw==", - "deprecated": "This package has been replaced by @base-ui-components/react", + "deprecated": "This package has been replaced by @base-ui/react", "license": "MIT", "dependencies": { "@babel/runtime": "^7.24.6", @@ -1905,13 +1890,13 @@ } }, "node_modules/@mui/base/node_modules/@mui/utils": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.6.tgz", - "integrity": "sha512-43nZeE1pJF2anGafNydUcYFPtHwAqiBiauRtaMvurdrZI3YrUjHkAu43RBsxef7OFtJMXGiHFvq43kb7lig0sA==", + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", + "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", - "@mui/types": "^7.2.21", + "@mui/types": "~7.2.24", "@types/prop-types": "^15.7.14", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -1935,9 +1920,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.16.14.tgz", - "integrity": "sha512-sbjXW+BBSvmzn61XyTMun899E7nGPTXwqD9drm1jBUAvWEhJpPFIRxwQQiATWZnd9rvdxtnhhdsDxEGWI0jxqA==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1945,9 +1930,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.16.14.tgz", - "integrity": "sha512-heL4S+EawrP61xMXBm59QH6HODsu0gxtZi5JtnXF2r+rghzyU/3Uftlt1ij8rmJh+cFdKTQug1L9KkZB5JgpMQ==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9" @@ -1971,16 +1956,16 @@ } }, "node_modules/@mui/material": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.16.14.tgz", - "integrity": "sha512-eSXQVCMKU2xc7EcTxe/X/rC9QsV2jUe8eLM3MUCPYbo6V52eCE436akRIvELq/AqZpxx2bwkq7HC0cRhLB+yaw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.16.14", - "@mui/system": "^5.16.14", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.14", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", @@ -2016,13 +2001,13 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.16.14.tgz", - "integrity": "sha512-12t7NKzvYi819IO5IapW2BcR33wP/KAVrU8d7gLhGHoAmhDxyXlRoKiRij3TOD8+uzk0B6R9wHUNKi4baJcRNg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.17.1.tgz", + "integrity": "sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.16.14", + "@mui/utils": "^5.17.1", "prop-types": "^15.8.1" }, "engines": { @@ -2043,13 +2028,14 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz", - "integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, @@ -2075,16 +2061,16 @@ } }, "node_modules/@mui/system": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.16.14.tgz", - "integrity": "sha512-KBxMwCb8mSIABnKvoGbvM33XHyT+sN0BzEBG+rsSc0lLQGzs7127KWkCA6/H8h6LZ00XpBEME5MAj8mZLiQ1tw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.16.14", - "@mui/styled-engine": "^5.16.14", - "@mui/types": "^7.2.15", - "@mui/utils": "^5.16.14", + "@mui/private-theming": "^5.17.1", + "@mui/styled-engine": "^5.18.0", + "@mui/types": "~7.2.15", + "@mui/utils": "^5.17.1", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -2115,13 +2101,10 @@ } }, "node_modules/@mui/types": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", - "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", + "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.3" - }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -2132,13 +2115,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.16.14.tgz", - "integrity": "sha512-wn1QZkRzSmeXD1IguBVvJJHV3s6rxJrfb6YuC9Kk6Noh9f8Fb54nUs5JRkKm+BOerRhj5fLg05Dhx/H3Ofb8Mg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.17.1.tgz", + "integrity": "sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/types": "^7.2.15", + "@mui/types": "~7.2.15", "@types/prop-types": "^15.7.12", "clsx": "^2.1.1", "prop-types": "^15.8.1", @@ -2162,21 +2145,21 @@ } }, "node_modules/@mui/x-charts": { - "version": "8.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.0.0-beta.3.tgz", - "integrity": "sha512-3SYH5DoMv/xL0gGo7xKtuTu2GsNlgHCur7zalP7kWeIjTgCXib+ZUixGEMdfdyRcDEADkXWFssYw2QhsXA+rNg==", + "version": "8.27.5", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.27.5.tgz", + "integrity": "sha512-45XAKzEaTXx8D612zAghr6ofNK/OHukKTl9kuI+UmpaOE3se+khNwKHeOyXcus2uUoGoL6jxZcENklZmJDxzCg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.0", - "@mui/utils": "^7.0.0", - "@mui/x-charts-vendor": "8.0.0-beta.3", - "@mui/x-internals": "8.0.0-beta.3", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-charts-vendor": "8.26.0", + "@mui/x-internal-gestures": "0.4.0", + "@mui/x-internals": "8.26.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^18.3.1 || ^19.0.0", "reselect": "^5.1.1", - "use-sync-external-store": "^1.4.0" + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -2199,96 +2182,65 @@ } }, "node_modules/@mui/x-charts-vendor": { - "version": "8.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.0.0-beta.3.tgz", - "integrity": "sha512-mcelNPzVYyrU8yVkW/CcTGw0doFLtSFj1Pw8q8LghvJW3rMJUeoHxU2WVOUU2+ha4sHSlEBCPwRZvJBJnoWyqA==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.26.0.tgz", + "integrity": "sha512-R//+WSWvsLJRTjTRN90EKX9sgRzAb4HQBvtUA3cTQpkGrmEjmatD4BJAm3IdRdkSagf6yKWF+ypESctyRhbwnA==", "license": "MIT AND ISC", "dependencies": { - "@babel/runtime": "^7.27.0", + "@babel/runtime": "^7.28.4", + "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", - "@types/d3-delaunay": "^6.0.4", + "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.7", "@types/d3-time": "^3.0.4", + "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", "d3-color": "^3.1.0", - "d3-delaunay": "^6.0.4", + "d3-format": "^3.1.0", "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", - "delaunator": "^5.0.1", - "robust-predicates": "^3.0.2" - } - }, - "node_modules/@mui/x-charts-vendor/node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@mui/x-charts-vendor/node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" + "flatqueue": "^3.0.0", + "internmap": "^2.0.3" } }, - "node_modules/@mui/x-charts-vendor/node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", + "node_modules/@mui/x-charts/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", "dependencies": { - "d3-path": "^3.1.0" + "@babel/runtime": "^7.28.6" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@mui/x-charts-vendor/node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@mui/x-charts-vendor/node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@mui/x-charts/node_modules/@mui/utils": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.2.tgz", - "integrity": "sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==", + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", + "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.3", - "@mui/types": "^7.4.6", + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.1" + "react-is": "^19.2.3" }, "engines": { "node": ">=14.0.0" @@ -2307,35 +2259,15 @@ } } }, - "node_modules/@mui/x-charts/node_modules/@mui/x-internals": { - "version": "8.0.0-beta.3", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.0.0-beta.3.tgz", - "integrity": "sha512-crbtLMWhI0sFXaZLknXPEGEaPLxpdIe8XAkJIr0HXD563TagGeyVk8lbNLoa5H3mVHWxmzNYiGUA4ns5Q6urQg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.27.0", - "@mui/utils": "^7.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@mui/x-data-grid": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.27.3.tgz", - "integrity": "sha512-7zbDbFrhV6ODjyn3ImOZG34nbMbCvmHgqYTYP273TNAj8hMy4BiLyiKFFZTzVddIj3KQ6qLzBpByhqifGgEDOg==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.12.tgz", + "integrity": "sha512-MaEC7ubr/je8jVWjdRU7LxBXAzlOZwFEdNdvlDUJIYkRa3TRCQ1HsY8Gd8Od0jnlnMYn9M4BrEfOrq9VRnt4bw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0", - "@mui/x-internals": "7.26.0", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", + "@mui/x-internals": "7.29.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", @@ -2351,8 +2283,8 @@ "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14 || ^6.0.0", - "@mui/system": "^5.15.14 || ^6.0.0", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -2365,6 +2297,26 @@ } } }, + "node_modules/@mui/x-data-grid/node_modules/@mui/x-internals": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", + "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/x-date-pickers": { "version": "6.20.2", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.20.2.tgz", @@ -2431,14 +2383,25 @@ } } }, + "node_modules/@mui/x-internal-gestures": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-0.4.0.tgz", + "integrity": "sha512-i0W6v9LoiNY8Yf1goOmaygtz/ncPJGBedhpDfvNg/i8BvzPwJcBaeW4rqPucJfVag9KQ8MSssBBrvYeEnrQmhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, "node_modules/@mui/x-internals": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", - "integrity": "sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==", + "version": "8.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.26.0.tgz", + "integrity": "sha512-B9OZau5IQUvIxwpJZhoFJKqRpmWf5r0yMmSXjQuqb5WuqM755EuzWJOenY48denGoENzMLT8hQpA0hRTeU2IPA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0" + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -2451,42 +2414,51 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@mui/x-internals/node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@babel/runtime": "^7.28.6" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "7.3.9", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.9.tgz", + "integrity": "sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" }, "engines": { - "node": ">= 8" + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@plotly/d3": { @@ -2518,6 +2490,48 @@ "elementary-circuits-directed-graph": "^1.0.4" } }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey-circular/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, "node_modules/@plotly/mapbox-gl": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", @@ -2600,16 +2614,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -2621,9 +2635,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -2635,9 +2649,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -2649,9 +2663,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -2663,9 +2677,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -2677,9 +2691,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -2691,9 +2705,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -2705,9 +2719,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -2719,9 +2733,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -2733,9 +2747,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -2747,9 +2761,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -2761,9 +2789,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -2775,9 +2817,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -2789,9 +2831,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -2803,9 +2845,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -2817,9 +2859,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -2831,9 +2873,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -2844,10 +2886,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -2859,9 +2915,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -2873,9 +2929,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -2887,9 +2943,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -2901,9 +2957,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2915,16 +2971,16 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "dev": true, "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz", - "integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", + "integrity": "sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -2940,16 +2996,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.3", - "@swc/core-darwin-x64": "1.15.3", - "@swc/core-linux-arm-gnueabihf": "1.15.3", - "@swc/core-linux-arm64-gnu": "1.15.3", - "@swc/core-linux-arm64-musl": "1.15.3", - "@swc/core-linux-x64-gnu": "1.15.3", - "@swc/core-linux-x64-musl": "1.15.3", - "@swc/core-win32-arm64-msvc": "1.15.3", - "@swc/core-win32-ia32-msvc": "1.15.3", - "@swc/core-win32-x64-msvc": "1.15.3" + "@swc/core-darwin-arm64": "1.15.18", + "@swc/core-darwin-x64": "1.15.18", + "@swc/core-linux-arm-gnueabihf": "1.15.18", + "@swc/core-linux-arm64-gnu": "1.15.18", + "@swc/core-linux-arm64-musl": "1.15.18", + "@swc/core-linux-x64-gnu": "1.15.18", + "@swc/core-linux-x64-musl": "1.15.18", + "@swc/core-win32-arm64-msvc": "1.15.18", + "@swc/core-win32-ia32-msvc": "1.15.18", + "@swc/core-win32-x64-msvc": "1.15.18" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -2961,9 +3017,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz", - "integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", + "integrity": "sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==", "cpu": [ "arm64" ], @@ -2978,9 +3034,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz", - "integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.18.tgz", + "integrity": "sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==", "cpu": [ "x64" ], @@ -2995,9 +3051,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz", - "integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.18.tgz", + "integrity": "sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==", "cpu": [ "arm" ], @@ -3012,9 +3068,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz", - "integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.18.tgz", + "integrity": "sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==", "cpu": [ "arm64" ], @@ -3029,9 +3085,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz", - "integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.18.tgz", + "integrity": "sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==", "cpu": [ "arm64" ], @@ -3046,9 +3102,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz", - "integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.18.tgz", + "integrity": "sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==", "cpu": [ "x64" ], @@ -3063,9 +3119,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz", - "integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.18.tgz", + "integrity": "sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==", "cpu": [ "x64" ], @@ -3080,9 +3136,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz", - "integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.18.tgz", + "integrity": "sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==", "cpu": [ "arm64" ], @@ -3097,9 +3153,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz", - "integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.18.tgz", + "integrity": "sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==", "cpu": [ "ia32" ], @@ -3114,9 +3170,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz", - "integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==", + "version": "1.15.18", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.18.tgz", + "integrity": "sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==", "cpu": [ "x64" ], @@ -3161,14 +3217,14 @@ } }, "node_modules/@tanstack/react-router": { - "version": "1.163.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.163.3.tgz", - "integrity": "sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==", + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.166.7.tgz", + "integrity": "sha512-LLcXu2nrCn2WL+w0YAbg3CRZIIO2cYVSC3y+ZYlFBxBs4hh8eoNP1EWFvRLZGCFYpqON7x6qUf1u0W7tH0cJJw==", "license": "MIT", "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", - "@tanstack/router-core": "1.163.3", + "@tanstack/router-core": "1.166.7", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" @@ -3186,12 +3242,12 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", - "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.2.tgz", + "integrity": "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.9.1", + "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "funding": { @@ -3204,9 +3260,9 @@ } }, "node_modules/@tanstack/router-core": { - "version": "1.163.3", - "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.163.3.tgz", - "integrity": "sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==", + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.166.7.tgz", + "integrity": "sha512-MCc8wYIIcxmbeidM8PL2QeaAjUIHyhEDIZPW6NGfn/uwvyi+K2ucn3AGCxxcXl4JGGm0Mx9+7buYl1v3HdcFrg==", "license": "MIT", "dependencies": { "@tanstack/history": "1.161.4", @@ -3226,13 +3282,13 @@ } }, "node_modules/@tanstack/router-generator": { - "version": "1.163.3", - "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.163.3.tgz", - "integrity": "sha512-i2rWRtqY/yCYUDXva1li4zeDP20oFjMt/wh9RnGJCrKSLWrvEGnxAOSyXgiOsoJnU96TTQ0mUDbGfXsSTupeZQ==", + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.166.7.tgz", + "integrity": "sha512-lBI0VS7J1zMrJhfvT+3FMq9jPdOrJ3VgciPXyYvZBF/a9Mr8T94MU78PqrBNuJbYh7qCFO14ZhArUFqkYGuozQ==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/router-core": "1.163.3", + "@tanstack/router-core": "1.166.7", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "prettier": "^3.5.0", @@ -3260,9 +3316,9 @@ } }, "node_modules/@tanstack/router-plugin": { - "version": "1.163.3", - "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.163.3.tgz", - "integrity": "sha512-JOUYuUX2N9ZHnmkmvmiGzXGbkvrur/5BfW/+vpiZzuifSyvdc0XsfwkTpjvwWx9ymp4ZshSVKiQQKQi09YweIw==", + "version": "1.166.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.166.7.tgz", + "integrity": "sha512-R06qe5UwApb/u02wDITVxN++6QE4xsLFQCr029VZ+4V8gyIe35kr8UCg3Jiyl6D5GXxhj62U2Ei8jccdkQaivw==", "dev": true, "license": "MIT", "dependencies": { @@ -3272,8 +3328,8 @@ "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", - "@tanstack/router-core": "1.163.3", - "@tanstack/router-generator": "1.163.3", + "@tanstack/router-core": "1.166.7", + "@tanstack/router-generator": "1.166.7", "@tanstack/router-utils": "1.161.4", "@tanstack/virtual-file-routes": "1.161.4", "chokidar": "^3.6.0", @@ -3289,7 +3345,7 @@ }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", - "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router": "^1.166.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" @@ -3338,9 +3394,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", - "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.2.tgz", + "integrity": "sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==", "license": "MIT", "funding": { "type": "github", @@ -3362,13 +3418,13 @@ } }, "node_modules/@turf/area": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", - "integrity": "sha512-zuTTdQ4eoTI9nSSjerIy4QwgvxqwJVciQJ8tOPuMHbXJ9N/dNjI7bU8tasjhxas/Cx3NE9NxVHtNpYHL0FSzoA==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.4.tgz", + "integrity": "sha512-UEQQFw2XwHpozSBAMEtZI3jDsAad4NnHL/poF7/S6zeDCjEBCkt3MYd6DSGH/cvgcOozxH/ky3/rIVSMZdx4vA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -3377,13 +3433,13 @@ } }, "node_modules/@turf/bbox": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.2.0.tgz", - "integrity": "sha512-wzHEjCXlYZiDludDbXkpBSmv8Zu6tPGLmJ1sXQ6qDwpLE1Ew3mcWqt8AaxfTP5QwDNQa3sf2vvgTEzNbPQkCiA==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.4.tgz", + "integrity": "sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -3392,13 +3448,13 @@ } }, "node_modules/@turf/centroid": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.2.0.tgz", - "integrity": "sha512-yJqDSw25T7P48au5KjvYqbDVZ7qVnipziVfZ9aSo7P2/jTE7d4BP21w0/XLi3T/9bry/t9PR1GDDDQljN4KfDw==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.4.tgz", + "integrity": "sha512-6c3kyTSKBrmiPMe75UkHw6MgedroZ6eR5usEvdlDhXgA3MudFPXIZkMFmMd1h9XeJ9xFfkmq+HPCdF0cOzvztA==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", + "@turf/helpers": "7.3.4", + "@turf/meta": "7.3.4", "@types/geojson": "^7946.0.10", "tslib": "^2.8.1" }, @@ -3407,9 +3463,9 @@ } }, "node_modules/@turf/helpers": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", - "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz", + "integrity": "sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==", "license": "MIT", "dependencies": { "@types/geojson": "^7946.0.10", @@ -3420,28 +3476,35 @@ } }, "node_modules/@turf/meta": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.2.0.tgz", - "integrity": "sha512-igzTdHsQc8TV1RhPuOLVo74Px/hyPrVgVOTgjWQZzt3J9BVseCdpfY/0cJBdlSRI4S/yTmmHl7gAqjhpYH5Yaw==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.4.tgz", + "integrity": "sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==", "license": "MIT", "dependencies": { - "@turf/helpers": "^7.2.0", - "@types/geojson": "^7946.0.10" + "@turf/helpers": "7.3.4", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" }, "funding": { "url": "https://opencollective.com/turf" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { @@ -3469,9 +3532,9 @@ } }, "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -3483,6 +3546,12 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", @@ -3584,9 +3653,9 @@ "license": "MIT" }, "node_modules/@types/leaflet": { - "version": "1.9.16", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.16.tgz", - "integrity": "sha512-wzZoyySUxkgMZ0ihJ7IaUIblG8Rdc8AbbZKLneyn+QjYsj5q1QU7TEKYqwTr10BGSzY5LI7tJk9Ifo+mEjdFRw==", + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3611,9 +3680,9 @@ } }, "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3632,9 +3701,9 @@ "license": "MIT" }, "node_modules/@types/plotly.js": { - "version": "2.35.2", - "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.2.tgz", - "integrity": "sha512-tn0Kp7F6VWiu96jknCvR/PcdIGIATeIK+Z5WXH3bEvG6CRwUNfhy34yBhfPYmTea7mMQxXvTZKGMm6/Y4wxESg==", + "version": "2.35.14", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.14.tgz", + "integrity": "sha512-CcD/32JcK19+xWH4FFpmYez/5X9kOjUcBr8Hxh7gQ/3Z32gIoLLy/L9xvC7DG5YikPvJjq6QN05B9+MCRu/Ncw==", "dev": true, "license": "MIT" }, @@ -3645,13 +3714,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", - "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-data-grid": { @@ -3666,9 +3735,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.3.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", - "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3676,9 +3745,9 @@ } }, "node_modules/@types/react-plotly.js": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.3.tgz", - "integrity": "sha512-HBQwyGuu/dGXDsWhnQrhH+xcJSsHvjkwfSRjP+YpOsCCWryIuXF78ZCBjpfgO3sCc0Jo8sYp4NOGtqT7Cn3epQ==", + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.4.tgz", + "integrity": "sha512-AU6w1u3qEGM0NmBA69PaOgNc0KPFA/+qkH6Uu9EBTJ45/WYOUoXi9AF5O15PRM2klpHSiHAAs4WnlI+OZAFmUA==", "dev": true, "license": "MIT", "dependencies": { @@ -3712,9 +3781,9 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -3729,21 +3798,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", - "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/type-utils": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3753,23 +3821,56 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", - "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3779,39 +3880,56 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", - "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", - "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3821,14 +3939,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", - "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "dev": true, "license": "MIT", "engines": { @@ -3840,20 +3958,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", - "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3863,46 +3982,72 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3912,19 +4057,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", - "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3934,15 +4079,28 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", - "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.3.tgz", + "integrity": "sha512-QIluDil2prhY1gdA3GGwxZzTAmLdi8cQ2CcuMW4PB/Wu4e/1pzqrwhYWVd09LInCRlDUidQjd0B70QWbjWtLxA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.47", - "@swc/core": "^1.13.5" + "@rolldown/pluginutils": "1.0.0-rc.2", + "@swc/core": "^1.15.11" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -4138,19 +4296,6 @@ "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", "license": "MIT" }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4163,6 +4308,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -4174,9 +4332,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -4209,9 +4367,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -4289,9 +4447,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -4463,6 +4621,18 @@ "node": ">= 0.6.0" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bezier-easing": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", @@ -4542,9 +4712,9 @@ } }, "node_modules/boxen/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -4554,9 +4724,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4593,9 +4763,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "funding": [ { "type": "opencollective", @@ -4612,10 +4782,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -4631,9 +4802,9 @@ "license": "MIT" }, "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -4661,9 +4832,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001702", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz", - "integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "funding": [ { "type": "opencollective", @@ -4745,19 +4916,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -4942,17 +5100,17 @@ } }, "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", + "bytes": "3.1.2", + "compressible": "~2.0.18", "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", "vary": "~1.1.2" }, "engines": { @@ -4974,12 +5132,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5121,19 +5273,19 @@ "license": "MIT" }, "node_modules/css-loader": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", - "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", "license": "MIT", "dependencies": { "icss-utils": "^5.1.0", - "postcss": "^8.4.33", + "postcss": "^8.4.40", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" + "semver": "^7.6.3" }, "engines": { "node": ">= 18.12.0" @@ -5143,7 +5295,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", "webpack": "^5.27.0" }, "peerDependenciesMeta": { @@ -5155,6 +5307,18 @@ } } }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/css-system-font-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", @@ -5180,9 +5344,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/d": { @@ -5199,10 +5363,16 @@ } }, "node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", - "license": "BSD-3-Clause" + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } }, "node_modules/d3-collection": { "version": "1.0.7", @@ -5219,18 +5389,6 @@ "node": ">=12" } }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-dispatch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", @@ -5249,12 +5407,21 @@ "d3-timer": "1" } }, - "node_modules/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "node_modules/d3-force/node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", "license": "BSD-3-Clause" }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-geo": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", @@ -5283,6 +5450,18 @@ "geostitch": "bin/geostitch" } }, + "node_modules/d3-geo-projection/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, "node_modules/d3-hierarchy": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", @@ -5302,10 +5481,13 @@ } }, "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "license": "BSD-3-Clause" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/d3-quadtree": { "version": "1.0.7", @@ -5329,19 +5511,19 @@ "node": ">=12" } }, - "node_modules/d3-scale/node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", "license": "ISC", "dependencies": { - "internmap": "1 - 2" + "d3-path": "^3.1.0" }, "engines": { "node": ">=12" } }, - "node_modules/d3-scale/node_modules/d3-time": { + "node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", @@ -5353,46 +5535,37 @@ "node": ">=12" } }, - "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "license": "BSD-3-Clause", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", - "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", - "license": "BSD-3-Clause" - }, "node_modules/d3-time-format": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", - "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", - "license": "BSD-3-Clause", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { - "d3-time": "1" + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, "node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", - "license": "BSD-3-Clause" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5431,15 +5604,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/detect-kerning": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", @@ -5532,9 +5696,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.113", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", - "integrity": "sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "license": "ISC" }, "node_modules/element-size": { @@ -5559,41 +5723,41 @@ "license": "MIT" }, "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "license": "MIT", "peer": true, "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT", "peer": true }, @@ -5650,9 +5814,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5663,31 +5827,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escalade": { @@ -5743,33 +5908,32 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5781,7 +5945,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5817,9 +5981,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", - "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5827,9 +5991,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5844,9 +6008,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5856,6 +6020,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esniff": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", @@ -5872,15 +6049,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5903,9 +6080,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6044,36 +6221,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-isnumeric": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", @@ -6098,9 +6245,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "funding": [ { "type": "github", @@ -6111,18 +6258,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } + "license": "BSD-3-Clause" }, "node_modules/file-entry-cache": { "version": "8.0.0", @@ -6187,10 +6323,16 @@ "node": ">=16" } }, + "node_modules/flatqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", + "integrity": "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==", + "license": "ISC" + }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -6315,9 +6457,9 @@ "license": "Zlib" }, "node_modules/gl-matrix": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", - "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", "license": "MIT" }, "node_modules/gl-text": { @@ -6364,7 +6506,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -6382,16 +6524,16 @@ } }, "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.3" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=10.13.0" + "node": ">= 6" } }, "node_modules/glob-to-regexp": { @@ -6416,12 +6558,12 @@ } }, "node_modules/global-prefix/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/global-prefix/node_modules/which": { @@ -6654,9 +6796,9 @@ } }, "node_modules/goober": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", - "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", "license": "MIT", "peerDependencies": { "csstype": "^3.0.10" @@ -6668,13 +6810,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/grid-index": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", @@ -6805,9 +6940,9 @@ } }, "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", "funding": { "type": "opencollective", @@ -7081,9 +7216,9 @@ "license": "MIT" }, "node_modules/isbot": { - "version": "5.1.35", - "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.35.tgz", - "integrity": "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==", + "version": "5.1.36", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.36.tgz", + "integrity": "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==", "license": "Unlicense", "engines": { "node": ">=18" @@ -7365,13 +7500,17 @@ "license": "MIT" }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", "peer": true, "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -7512,9 +7651,9 @@ } }, "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", - "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", "license": "BSD-2-Clause" }, "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { @@ -7524,9 +7663,9 @@ "license": "BSD-2-Clause" }, "node_modules/maplibre-gl/node_modules/earcut": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", - "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, "node_modules/maplibre-gl/node_modules/geojson-vt": { @@ -7536,9 +7675,9 @@ "license": "ISC" }, "node_modules/maplibre-gl/node_modules/potpack": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", - "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", "license": "ISC" }, "node_modules/maplibre-gl/node_modules/quickselect": { @@ -7587,16 +7726,6 @@ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7618,30 +7747,30 @@ "license": "MIT" }, "node_modules/mime-db": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", - "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "~1.33.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7657,9 +7786,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -7798,9 +7927,9 @@ } }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -7820,9 +7949,9 @@ "license": "ISC" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "license": "MIT" }, "node_modules/normalize-path": { @@ -7912,9 +8041,9 @@ "license": "MIT" }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -8217,6 +8346,27 @@ "world-calendars": "^1.0.3" } }, + "node_modules/plotly.js/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/plotly.js/node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, "node_modules/point-in-polygon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", @@ -8230,9 +8380,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -8317,9 +8467,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -8452,32 +8602,12 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/quickselect": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", @@ -8493,16 +8623,6 @@ "performance-now": "^2.1.0" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -8594,9 +8714,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.54.2", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", - "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -8610,9 +8730,9 @@ } }, "node_modules/react-is": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", - "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", "license": "MIT" }, "node_modules/react-leaflet": { @@ -8630,9 +8750,9 @@ } }, "node_modules/react-number-format": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.3.tgz", - "integrity": "sha512-VCY5hFg/soBighAoGcdE+GagkJq0230qN6jcS5sp8wQX1qy1fYN/RX7/BXkrs0oyzzwqR8/+eSUrqXbGeywdUQ==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -8917,12 +9037,12 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -8964,17 +9084,6 @@ "protocol-buffers-schema": "^3.3.1" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/right-now": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", @@ -8997,16 +9106,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "license": "Unlicense" - }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -9020,55 +9123,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -9102,10 +9184,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/scheduler": { "version": "0.23.2", @@ -9117,9 +9202,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", - "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "peer": true, "dependencies": { @@ -9137,9 +9222,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "peer": true, "dependencies": { @@ -9174,40 +9259,28 @@ "peer": true }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "randombytes": "^2.1.0" } }, "node_modules/seroval": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", - "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.1.tgz", + "integrity": "sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==", "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/seroval-plugins": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", - "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.1.tgz", + "integrity": "sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==", "license": "MIT", "engines": { "node": ">=10" @@ -9217,21 +9290,21 @@ } }, "node_modules/serve": { - "version": "14.2.4", - "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz", - "integrity": "sha512-qy1S34PJ/fcY8gjVGszDB3EXiPSk5FKhUa7tQe0UPRddxRidc2V6cNHPNewbE1D7MAkgLuWEt3Vw56vYy73tzQ==", + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", "license": "MIT", "dependencies": { "@zeit/schemas": "2.36.0", - "ajv": "8.12.0", + "ajv": "8.18.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", - "compression": "1.7.4", + "compression": "1.8.1", "is-port-reachable": "4.0.0", - "serve-handler": "6.1.6", + "serve-handler": "6.1.7", "update-check": "1.5.4" }, "bin": { @@ -9242,51 +9315,39 @@ } }, "node_modules/serve-handler": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", - "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", "license": "MIT", "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", - "minimatch": "3.1.2", + "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, - "node_modules/serve-handler/node_modules/mime-db": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", - "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-handler/node_modules/mime-types": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", - "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", "license": "MIT", - "dependencies": { - "mime-db": "~1.33.0" - }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/serve/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -9511,12 +9572,12 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -9661,24 +9722,28 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "peer": true, "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "license": "BSD-2-Clause", "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -9690,16 +9755,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -9861,9 +9925,9 @@ "license": "MIT" }, "node_modules/ts-api-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", - "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -9899,587 +9963,121 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "prelude-ls": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": ">= 0.8.0" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=18" + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=18" + "node": ">=14.17" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" + }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unload": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", + "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.6.2", + "detect-node": "^2.0.4" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "license": "ISC" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/typedarray-pool": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", - "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", - "license": "MIT", - "dependencies": { - "bit-twiddle": "^1.0.0", - "dup": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.0.tgz", - "integrity": "sha512-PtVz9nAnuNJuAVeUFvwztjuUgSnJInODAUx47VDwWPXzd5vismPOtPtt83tzNXyOjVQbPRp786D6WFW/M2koIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "@typescript-eslint/utils": "8.26.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unload": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz", - "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.6.2", - "detect-node": "^2.0.4" - } - }, - "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=18.12.0" + "node": ">=18.12.0" } }, "node_modules/unplugin/node_modules/picomatch": { @@ -10502,9 +10100,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -10551,6 +10149,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -10593,13 +10192,13 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -10710,9 +10309,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "peer": true, "dependencies": { @@ -10739,35 +10338,37 @@ } }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -10786,9 +10387,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "license": "MIT", "peer": true, "engines": { @@ -10826,6 +10427,29 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10867,9 +10491,9 @@ } }, "node_modules/world-calendars": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.3.tgz", - "integrity": "sha512-sAjLZkBnsbHkHWVhrsCU5Sa/EVuf9QqgvrN8zyJ2L/F9FR9Oc6CvVK0674+PGAtmmmYQMH98tCUSO4QLQv3/TQ==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", "license": "MIT", "dependencies": { "object-assign": "^4.1.0" @@ -10893,9 +10517,9 @@ } }, "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -10927,9 +10551,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", "optional": true, @@ -10938,7 +10562,10 @@ "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yocto-queue": { @@ -10955,9 +10582,9 @@ } }, "node_modules/yup": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", - "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", "dependencies": { "property-expr": "^2.0.5", diff --git a/frontend/package.json b/frontend/package.json index e9bfeb0c..d398cab1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,4 +62,4 @@ "typescript-eslint": "^8.24.1", "vite": "^7.2.6" } -} +} \ No newline at end of file From ca9c389d82c2e9d14bd85cdd2329be2f864dbc5a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Mar 2026 00:02:51 -0500 Subject: [PATCH 71/91] feat(public): Add resize to public data --- .../src/components/ResizableSplitPanels.tsx | 153 +++++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/routes/chlorides.tsx | 88 +++++++-- frontend/src/routes/monitoringwells.tsx | 82 ++++++-- .../src/views/Chlorides/ChloridesPlot.tsx | 93 ++++++--- .../MonitoringWells/MonitoringWellsPlot.tsx | 182 +++++++++++++----- 6 files changed, 496 insertions(+), 103 deletions(-) create mode 100644 frontend/src/components/ResizableSplitPanels.tsx diff --git a/frontend/src/components/ResizableSplitPanels.tsx b/frontend/src/components/ResizableSplitPanels.tsx new file mode 100644 index 00000000..35554b8b --- /dev/null +++ b/frontend/src/components/ResizableSplitPanels.tsx @@ -0,0 +1,153 @@ +import { ReactNode, useEffect, useRef, useState } from "react"; +import { Box, useMediaQuery, useTheme } from "@mui/material"; +import { alpha } from "@mui/material/styles"; + +type ResizableSplitPanelsProps = { + left: ReactNode; + right: ReactNode; + leftWidth?: number; + defaultLeftWidth?: number; + minLeftWidth?: number; + minRightWidth?: number; + desktopBreakpoint?: "sm" | "md" | "lg" | "xl"; + onLeftWidthChange?: (leftWidth: number) => void; +}; + +export const ResizableSplitPanels = ({ + left, + right, + leftWidth: controlledLeftWidth, + defaultLeftWidth = 58, + minLeftWidth = 35, + minRightWidth = 28, + desktopBreakpoint = "lg", + onLeftWidthChange, +}: ResizableSplitPanelsProps) => { + const theme = useTheme(); + const isDesktop = useMediaQuery(theme.breakpoints.up(desktopBreakpoint)); + const containerRef = useRef(null); + const resizeStateRef = useRef<{ + startX: number; + startLeftWidth: number; + containerWidth: number; + } | null>(null); + const [uncontrolledLeftWidth, setUncontrolledLeftWidth] = + useState(defaultLeftWidth); + const leftWidth = controlledLeftWidth ?? uncontrolledLeftWidth; + + useEffect(() => { + if (controlledLeftWidth === undefined) { + return; + } + + setUncontrolledLeftWidth(controlledLeftWidth); + }, [controlledLeftWidth]); + + useEffect(() => { + if (!isDesktop) { + return undefined; + } + + const handleMouseMove = (event: MouseEvent) => { + if (!resizeStateRef.current) { + return; + } + + const { startX, startLeftWidth, containerWidth } = resizeStateRef.current; + const dragDelta = event.clientX - startX; + const dragPercent = (dragDelta / containerWidth) * 100; + const maxLeftWidth = 100 - minRightWidth; + const nextLeftWidth = Math.min( + maxLeftWidth, + Math.max(minLeftWidth, startLeftWidth + dragPercent), + ); + + setUncontrolledLeftWidth(nextLeftWidth); + onLeftWidthChange?.(nextLeftWidth); + }; + + const handleMouseUp = () => { + resizeStateRef.current = null; + }; + + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDesktop, minLeftWidth, minRightWidth, onLeftWidthChange]); + + const handleResizeStart = (event: React.MouseEvent) => { + if (!containerRef.current) { + return; + } + + event.preventDefault(); + resizeStateRef.current = { + startX: event.clientX, + startLeftWidth: leftWidth, + containerWidth: containerRef.current.getBoundingClientRect().width, + }; + }; + + if (!isDesktop) { + return ( + + {left} + {right} + + ); + } + + return ( + + + {left} + + + + {right} + + + ); +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 99b1b09e..fbf38edf 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -23,6 +23,7 @@ export * from "./Modals"; export * from "./NavLink"; export * from "./ReportsNavItem"; export * from "./RHControlled"; +export * from "./ResizableSplitPanels"; export * from "./RoleChip"; export * from "./StatCell"; export * from "./StyledToggleButton"; diff --git a/frontend/src/routes/chlorides.tsx b/frontend/src/routes/chlorides.tsx index 2836486a..22955d28 100644 --- a/frontend/src/routes/chlorides.tsx +++ b/frontend/src/routes/chlorides.tsx @@ -1,4 +1,4 @@ -import { useId, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { FormControl, @@ -10,7 +10,6 @@ import { Alert, Button, AlertTitle, - Grid, } from "@mui/material"; import { Science } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; @@ -27,7 +26,11 @@ import { RegionMeasurementDTO, } from "@/interfaces"; import { useFetchWithAuth } from "@/hooks"; -import { BackgroundBox, CustomCardHeader } from "@/components"; +import { + BackgroundBox, + CustomCardHeader, + ResizableSplitPanels, +} from "@/components"; import { emptyToNull, optionalPositiveInt, @@ -35,14 +38,23 @@ import { routeSearchHydrator, } from "@/utils"; import { Table, Plot } from "@/views/Chlorides"; -import { BgColor } from "@/constants"; const searchSchema = z.object({ regionId: optionalPositiveInt.catch(undefined).default(undefined), page: pageParam(0, 0), pageSize: pageParam(25, 10), + split: z + .preprocess((val) => { + if (val === undefined || val === null || val === "") return undefined; + const n = Number(val); + return Number.isInteger(n) && n >= 35 && n <= 72 ? n : undefined; + }, z.number().int().min(35).max(72).optional()) + .catch(undefined) + .default(undefined), }); +const CHLORIDES_SPLIT_STORAGE_KEY = "chlorides-split-width"; + export const Route = createFileRoute("/chlorides")({ validateSearch: searchSchema, beforeLoad: ({ search, location }) => @@ -52,7 +64,7 @@ export const Route = createFileRoute("/chlorides")({ function Chlorides() { const navigate = useNavigate(); - const { regionId } = Route.useSearch(); + const { regionId, split } = Route.useSearch(); const { enqueueSnackbar } = useSnackbar(); const fetchWithAuth = useFetchWithAuth(); const uniqueSelectId = useId(); @@ -68,6 +80,37 @@ function Chlorides() { const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + useEffect(() => { + if (split !== undefined) { + return; + } + + const storedSplit = window.localStorage.getItem( + CHLORIDES_SPLIT_STORAGE_KEY, + ); + if (!storedSplit) { + return; + } + + const parsedSplit = Number(storedSplit); + if ( + !Number.isInteger(parsedSplit) || + parsedSplit < 35 || + parsedSplit > 72 + ) { + return; + } + + navigate({ + to: "/chlorides", + search: (prev) => ({ + ...(prev as any), + split: parsedSplit, + }), + replace: true, + }); + }, [navigate, split]); + const authUser = useAuthUser(); const isAdmin = authUser()?.user_role.security_scopes.some( (s: SecurityScope) => s.scope_string === "admin", @@ -233,7 +276,7 @@ function Chlorides() { return ( - + {error && ( @@ -270,7 +313,10 @@ function Chlorides() { const next = Number(e.target.value); navigate({ to: "/chlorides", - search: { regionId: next }, + search: (prev) => ({ + ...(prev as any), + regionId: next, + }), replace: true, }); }} @@ -291,8 +337,24 @@ function Chlorides() { ))} - - + { + const roundedSplit = Math.round(nextSplit); + window.localStorage.setItem( + CHLORIDES_SPLIT_STORAGE_KEY, + roundedSplit.toString(), + ); + navigate({ + to: "/chlorides", + search: (prev) => ({ + ...(prev as any), + split: roundedSplit, + }), + replace: true, + }); + }} + left={ m.timestamp) ?? []} @@ -303,16 +365,16 @@ function Chlorides() { })) ?? [] } /> - - + } + right={
    setIsNewModalOpen(true)} onMeasurementSelect={handleMeasurementSelect} /> - - + } + /> {authUser() && ( <> { + if (val === undefined || val === null || val === "") return undefined; + const n = Number(val); + return Number.isInteger(n) && n >= 35 && n <= 72 ? n : undefined; + }, z.number().int().min(35).max(72).optional()) + .catch(undefined) + .default(undefined), }); +const MONITORING_WELLS_SPLIT_STORAGE_KEY = "monitoringwells-split-width"; + export const Route = createFileRoute("/monitoringwells")({ validateSearch: searchSchema, beforeLoad: ({ search, location }) => @@ -58,7 +71,7 @@ function MonitoringWells() { const theme = useTheme(); const navigate = useNavigate(); - const { wellId } = Route.useSearch(); + const { wellId, split } = Route.useSearch(); const queryClient = useQueryClient(); const fetchWithAuth = useFetchWithAuth(); const fetchSt2 = useFetchST2(); @@ -75,6 +88,33 @@ function MonitoringWells() { const [isNewModalOpen, setIsNewModalOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + useEffect(() => { + if (split !== undefined) { + return; + } + + const storedSplit = window.localStorage.getItem( + MONITORING_WELLS_SPLIT_STORAGE_KEY, + ); + if (!storedSplit) { + return; + } + + const parsedSplit = Number(storedSplit); + if (!Number.isInteger(parsedSplit) || parsedSplit < 35 || parsedSplit > 72) { + return; + } + + navigate({ + to: "/monitoringwells", + search: (prev) => ({ + ...(prev as any), + split: parsedSplit, + }), + replace: true, + }); + }, [navigate, split]); + const authUser = useAuthUser(); const isAdmin = authUser()?.user_role.security_scopes.some( (s: SecurityScope) => s.scope_string === "admin", @@ -275,7 +315,10 @@ function MonitoringWells() { const next = Number(e.target.value); navigate({ to: "/monitoringwells", - search: { wellId: next }, + search: (prev) => ({ + ...(prev as any), + wellId: next, + }), replace: true, }); }} @@ -350,12 +393,29 @@ function MonitoringWells() { ))} - - + { + const roundedSplit = Math.round(nextSplit); + window.localStorage.setItem( + MONITORING_WELLS_SPLIT_STORAGE_KEY, + roundedSplit.toString(), + ); + navigate({ + to: "/monitoringwells", + search: (prev) => ({ + ...(prev as any), + split: roundedSplit, + }), + replace: true, + }); + }} + left={ - - + } + right={
    setIsNewModalOpen(true)} onMeasurementSelect={handleMeasurementSelect} /> - - + } + /> {authUser() && ( <> { + const plotContainerRef = useRef(null); + const [plotRevision, setPlotRevision] = useState(0); + const data: Partial[] = useMemo(() => { const wellData: Record = {}; @@ -26,16 +29,40 @@ export const Plot = ({ return Object.entries(wellData).map(([well, { x, y }], index) => ({ x, y, - type: "scatter", + type: "scattergl", mode: "markers", marker: { color: generateColorScale(index) }, name: `Well ${well}`, })); }, [manual_dates, manual_vals]); + const hasData = data.length > 0; + + useEffect(() => { + const container = plotContainerRef.current; + if (!container) { + return undefined; + } + + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + setPlotRevision((prev) => prev + 1); + }); + }); + + observer.observe(container); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + return ( - {isLoading ? ( + {isLoading && !hasData ? ( ) : ( - + + + )} ); diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx index 2843aad0..ed3b3939 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; import ReactPlot from "react-plotly.js"; import { Data } from "plotly.js"; @@ -11,6 +11,7 @@ export const Plot = ({ sensor_dates, sensor_vals, isLoading, + isContinuousLoading = false, }: { manual_dates: Date[]; manual_vals: number[]; @@ -19,33 +20,49 @@ export const Plot = ({ sensor_dates?: Date[]; sensor_vals?: number[]; isLoading: boolean; + isContinuousLoading?: boolean; }) => { + const plotContainerRef = useRef(null); + const [plotRevision, setPlotRevision] = useState(0); + const data: Partial[] = useMemo( - () => [ - { - x: manual_dates, - y: manual_vals, - type: "scatter", - mode: "markers", - marker: { color: "red" }, - name: "Manual", - }, - { - x: logger_dates, - y: logger_vals, - type: "scatter", - marker: { color: "blue" }, - name: "Continuous", - }, - { - x: sensor_dates, - y: sensor_vals, - type: "scatter", - mode: "markers", - marker: { color: "purple" }, - name: "Woodpecker Sensor", - }, - ], + () => { + const traces: Partial[] = []; + + if (manual_dates.length > 0) { + traces.push({ + x: manual_dates, + y: manual_vals, + type: "scattergl", + mode: "markers", + marker: { color: "red" }, + name: "Manual", + }); + } + + if (logger_dates.length > 0) { + traces.push({ + x: logger_dates, + y: logger_vals, + type: "scattergl", + marker: { color: "blue" }, + name: "Continuous", + }); + } + + if (sensor_dates && sensor_dates.length > 0) { + traces.push({ + x: sensor_dates, + y: sensor_vals, + type: "scattergl", + mode: "markers", + marker: { color: "purple" }, + name: "Woodpecker Sensor", + }); + } + + return traces; + }, [ manual_dates, manual_vals, @@ -56,9 +73,33 @@ export const Plot = ({ ], ); + const hasData = data.length > 0; + + useEffect(() => { + const container = plotContainerRef.current; + if (!container) { + return undefined; + } + + let frame = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + setPlotRevision((prev) => prev + 1); + }); + }); + + observer.observe(container); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, []); + return ( - {isLoading ? ( + {isLoading && !hasData ? ( ) : ( - + + + + + {isContinuousLoading && ( + + + + Continuous data is still loading. More points will appear + automatically. + + + )} + )} ); From 1c9b631de173ede05c1b222ab5e6dcdbefb3df28 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Mar 2026 00:23:13 -0500 Subject: [PATCH 72/91] feat(plots): Add border to plots --- frontend/src/components/PlotContextMenu.tsx | 82 ++++++++++++ .../src/views/Chlorides/ChloridesPlot.tsx | 117 +++++++++++------ .../MonitoringWells/MonitoringWellsPlot.tsx | 119 ++++++++++++------ 3 files changed, 241 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/PlotContextMenu.tsx diff --git a/frontend/src/components/PlotContextMenu.tsx b/frontend/src/components/PlotContextMenu.tsx new file mode 100644 index 00000000..738b3553 --- /dev/null +++ b/frontend/src/components/PlotContextMenu.tsx @@ -0,0 +1,82 @@ +import { ReactNode, MouseEvent, useState } from "react"; +import { Box, Menu, MenuItem } from "@mui/material"; + +type ContextMenuPosition = { + mouseX: number; + mouseY: number; +}; + +export const PlotContextMenu = ({ + children, + onResetAxes, +}: { + children: ReactNode; + onResetAxes: () => void; +}) => { + const [contextMenu, setContextMenu] = useState( + null, + ); + + const handleContextMenu = (event: MouseEvent) => { + event.preventDefault(); + setContextMenu({ + mouseX: event.clientX + 2, + mouseY: event.clientY - 6, + }); + }; + + const handleClose = () => { + setContextMenu(null); + }; + + const handleResetAxes = () => { + handleClose(); + onResetAxes(); + }; + + return ( + + {children} + + + Reset axes + + + + ); +}; diff --git a/frontend/src/views/Chlorides/ChloridesPlot.tsx b/frontend/src/views/Chlorides/ChloridesPlot.tsx index a1a41bba..7e037428 100644 --- a/frontend/src/views/Chlorides/ChloridesPlot.tsx +++ b/frontend/src/views/Chlorides/ChloridesPlot.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; import ReactPlot from "react-plotly.js"; -import { Data } from "plotly.js"; +import type { Data } from "plotly.js"; +import { PlotContextMenu } from "../../components/PlotContextMenu"; export const Plot = ({ manual_dates, @@ -13,8 +14,23 @@ export const Plot = ({ isLoading: boolean; }) => { const plotContainerRef = useRef(null); + const plotRef = useRef(null); const [plotRevision, setPlotRevision] = useState(0); + const resetAxes = () => { + if (!plotRef.current) { + return; + } + + const resetAxesButton = plotRef.current.querySelector( + '.modebar-btn[data-title="Reset axes"]', + ); + + if (resetAxesButton) { + resetAxesButton.click(); + } + }; + const data: Partial[] = useMemo(() => { const wellData: Record = {}; @@ -61,7 +77,19 @@ export const Plot = ({ }, []); return ( - + {isLoading && !hasData ? ( ) : ( - - - + + + { + plotRef.current = graphDiv; + }} + onUpdate={(_, graphDiv) => { + plotRef.current = graphDiv; + }} + config={{ + displaylogo: false, + responsive: true, + modeBarButtonsToRemove: [ + "select2d", + "lasso2d", + "autoScale2d", + ], + }} + useResizeHandler + style={{ width: "100%", height: "100%" }} + /> + + )} ); diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx index ed3b3939..cc4b89fd 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsPlot.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Box, CircularProgress, Typography } from "@mui/material"; import ReactPlot from "react-plotly.js"; -import { Data } from "plotly.js"; +import type { Data } from "plotly.js"; +import { PlotContextMenu } from "../../components/PlotContextMenu"; export const Plot = ({ manual_dates, @@ -23,8 +24,23 @@ export const Plot = ({ isContinuousLoading?: boolean; }) => { const plotContainerRef = useRef(null); + const plotRef = useRef(null); const [plotRevision, setPlotRevision] = useState(0); + const resetAxes = () => { + if (!plotRef.current) { + return; + } + + const resetAxesButton = plotRef.current.querySelector( + '.modebar-btn[data-title="Reset axes"]', + ); + + if (resetAxesButton) { + resetAxesButton.click(); + } + }; + const data: Partial[] = useMemo( () => { const traces: Partial[] = []; @@ -98,7 +114,19 @@ export const Plot = ({ }, []); return ( - + {isLoading && !hasData ? ( ) : ( - - - + + + { + plotRef.current = graphDiv; + }} + onUpdate={(_, graphDiv) => { + plotRef.current = graphDiv; + }} + config={{ + displaylogo: false, + responsive: true, + modeBarButtonsToRemove: [ + "select2d", + "lasso2d", + "autoScale2d", + ], + }} + useResizeHandler + style={{ width: "100%", height: "100%" }} + /> + + {isContinuousLoading && ( Date: Thu, 12 Mar 2026 15:48:02 -0500 Subject: [PATCH 73/91] feat(UserAvatar): Add customer default avatar --- frontend/src/components/Topbar.tsx | 2 +- frontend/src/components/TopbarUserButton.tsx | 49 +++------------- frontend/src/components/UserAvatar.tsx | 57 +++++++++++++++++++ frontend/src/components/index.ts | 1 + .../src/views/UserManagement/UsersTable.tsx | 28 +++++++++ 5 files changed, 96 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/UserAvatar.tsx diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 24d11e32..ccf1e8b5 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -275,7 +275,7 @@ export const Topbar = ({ diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index 691c0c42..5b08e57a 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -1,46 +1,19 @@ -import { Avatar, ButtonProps, useTheme, IconButton } from "@mui/material"; -import { Badge, Engineering, Face } from "@mui/icons-material"; +import { ButtonProps, IconButton } from "@mui/material"; import { getRoleColor } from "@/utils"; +import { UserAvatar } from "./UserAvatar"; export const TopbarUserButton = ({ - display_name, + full_name, role, src, ...buttonProps }: { - display_name: string; + full_name: string; role: string; src?: string; } & ButtonProps) => { - const theme = useTheme(); const buttonColor = getRoleColor(role); - const primary = theme.palette.primary; - const secondary = theme.palette.secondary; - const warning = theme.palette.warning; - - const roleIcons: Record = { - Admin: , - Technician: ( - - ), - }; - - const renderRoleIcon = () => - roleIcons[role] ?? ; - - const roleBgColor: Record = { - Admin: primary.dark, - Technician: secondary.dark, - OSE: warning.dark, - }; - - const roleBorderColor: Record = { - Admin: primary.contrastText, - Technician: secondary.contrastText, - OSE: warning.contrastText, - }; - return ( - - {src ? null : renderRoleIcon()} - + /> ); }; diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 00000000..c2f19d46 --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,57 @@ +import { Avatar, AvatarProps, useTheme } from "@mui/material"; +import { createAvatar } from "@dicebear/core"; +import { notionists } from "@dicebear/collection"; + +export const UserAvatar = ({ + full_name, + role, + src, + size = 40, + ...avatarProps +}: { + full_name: string; + role?: string; + src?: string | null; + size?: number; +} & AvatarProps) => { + const theme = useTheme(); + const primary = theme.palette.primary; + const secondary = theme.palette.secondary; + const warning = theme.palette.warning; + + const roleBgColor: Record = { + Admin: primary.dark, + Technician: secondary.dark, + OSE: warning.dark, + }; + + const roleRingColor: Record = { + Admin: primary.main, + Technician: secondary.main, + OSE: warning.main, + }; + + const fallbackSrc = src + ? src + : createAvatar(notionists, { + seed: full_name, + size: 64, + }).toDataUri(); + + return ( + + ); +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index fbf38edf..0bdf2c95 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -31,6 +31,7 @@ export * from "./TabPanel"; export * from "./Topbar"; export * from "./TopbarUserButton"; export * from "./TristateToggle"; +export * from "./UserAvatar"; export * from "./UserSelection"; export * from "./WellMapLegend"; export * from "./WellSelection"; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 77bbe281..3e67f532 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { + Box, Button, Card, CardContent, @@ -19,6 +20,7 @@ import { IsTrueChip, RoleChip, TristateToggle, + UserAvatar, } from "@/components"; export const UsersTable = ({ @@ -66,6 +68,32 @@ export const UsersTable = ({ }, [usersList.data, search.user_q, search.active, search.tech]); const cols: GridColDef[] = [ + { + field: "avatar_img", + headerName: "Avatar", + width: 70, + sortable: false, + filterable: false, + renderCell: (params: any) => ( + + + + ), + }, { field: "full_name", headerName: "Full Name", width: 200 }, { field: "user_role", From 7fc34a2dd1f22be72b073280b59e8701efaf4d15 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Mar 2026 16:08:45 -0500 Subject: [PATCH 74/91] feat(Login): Now can login in with either your username or email --- api/security.py | 26 ++++++++++++++++++++------ frontend/src/service/ApiServiceNew.ts | 4 ++-- frontend/src/views/Login.tsx | 12 ++++++------ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/api/security.py b/api/security.py index cf45b50a..ddb0fb2d 100644 --- a/api/security.py +++ b/api/security.py @@ -6,6 +6,7 @@ from jose import jwt, ExpiredSignatureError from passlib.context import CryptContext from starlette import status +from sqlalchemy import or_ from sqlalchemy.orm import joinedload, undefer, Session from sqlalchemy.sql import select @@ -40,8 +41,8 @@ # Return the current user if credentials were correct, False if not -def authenticate_user(username: str, password: str, db: Session): - user = get_user(username, db) +def authenticate_user(login_identifier: str, password: str, db: Session): + user = get_user_by_login(login_identifier, db) if not user: return False if not verify_password(password, user.hashed_password): @@ -72,9 +73,8 @@ def get_password_hash(password): return pwd_context.hash(password) -def get_user(username: str, db: Session) -> Users: - # Load User with all security scopes - user_stmt = ( +def get_user_query(): + return ( select(Users) .options( undefer(Users.hashed_password), @@ -83,8 +83,22 @@ def get_user(username: str, db: Session) -> Users: undefer(Users.email), joinedload(Users.user_role).joinedload(UserRoles.security_scopes), ) - .filter(Users.username == username) ) + + +def get_user_by_login(login_identifier: str, db: Session) -> Users: + # Allow login via either username or email. + user_stmt = get_user_query().filter( + or_(Users.username == login_identifier, Users.email == login_identifier) + ) + dbuser = db.scalars(user_stmt).first() + + if dbuser: + return dbuser + + +def get_user(username: str, db: Session) -> Users: + user_stmt = get_user_query().filter(Users.username == username) dbuser = db.scalars(user_stmt).first() if dbuser: diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index c631b587..f3322cb6 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -468,8 +468,8 @@ export function useGetUser(id: number, options = {}) { const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery( - [route], + return useQuery( + [route, id], () => GETFetch(`${route}/${id}`, null, authHeader(), signOut, navigate), options, ); diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index 8fc0bc3d..6651bf33 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -24,7 +24,7 @@ import { API_URL } from "@/config"; import { CustomCardHeader } from "@/components"; export const Login = () => { - const [username, setUsername] = useState(""); + const [loginIdentifier, setLoginIdentifier] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -38,7 +38,7 @@ export const Login = () => { event.preventDefault(); const body = new FormData(); - body.append("username", username); + body.append("username", loginIdentifier); body.append("password", password); fetch(`${API_URL}/token`, { method: "POST", body }) @@ -82,7 +82,7 @@ export const Login = () => { localStorage.setItem("loggedIn", "true"); navigate({ to: data.user.redirect_page ?? "/" }); } else { - setError("Invalid username or password. Please try again."); + setError("Invalid username, email, or password. Please try again."); } }); } else { @@ -122,12 +122,12 @@ export const Login = () => { sx={{ paddingTop: "1.5rem", paddingBottom: "1.5rem" }} > setUsername(e.target.value)} + onChange={(e) => setLoginIdentifier(e.target.value)} /> Date: Thu, 12 Mar 2026 22:57:20 -0500 Subject: [PATCH 75/91] feat(notifications): Add route --- frontend/src/components/Topbar.tsx | 129 ++++++++++++++++--- frontend/src/components/TopbarUserButton.tsx | 6 +- frontend/src/routeTree.gen.ts | 21 +++ frontend/src/routes/notifications.tsx | 11 ++ frontend/src/views/Notifications.tsx | 109 ++++++++++++++++ frontend/src/views/index.ts | 1 + 6 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 frontend/src/routes/notifications.tsx create mode 100644 frontend/src/views/Notifications.tsx diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index ccf1e8b5..c21e7913 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -20,13 +20,14 @@ import { Home, Logout, MonitorHeart, + NotificationsOutlined, Public, Science, Settings, } from "@mui/icons-material"; import { useNavigate } from "@tanstack/react-router"; import { useAuthUser, useSignOut } from "react-auth-kit"; -import { RoleChip, TopbarUserButton } from "./index"; +import { TopbarUserButton, UserAvatar } from "@/components"; import { DESKTOP_COLLAPSED_WIDTH, TOPBAR_HEIGHT, @@ -53,6 +54,7 @@ export const Topbar = ({ const isHomeActive = useIsActiveRoute("/"); const isChloridesActive = useIsActiveRoute("/chlorides"); const isMonitoringWellsActive = useIsActiveRoute("/monitoringwells"); + const isNotificationsActive = useIsActiveRoute("/notifications"); const [userMenuAnchorEl, setUserMenuAnchorEl] = useState( null, @@ -61,6 +63,9 @@ export const Topbar = ({ useState(null); const role: string = authUser()?.user_role?.name; + const fullName = + authUser()?.full_name ?? authUser()?.display_name ?? "Unknown"; + const email = authUser()?.email ?? "No email available"; const isLoggedIn = !!authUser(); const isPublicDataActive = isChloridesActive || isMonitoringWellsActive; const effectiveSidebarWidth = @@ -272,10 +277,33 @@ export const Topbar = ({ ) : null} {isLoggedIn ? ( - + + navigate({ to: "/notifications", search: {} })} + sx={{ + width: { xs: 35, md: 40, lg: 44 }, + height: { xs: 35, md: 40, lg: 44 }, + color: isNotificationsActive ? "darkblue" : "text.secondary", + border: "1px solid", + borderColor: isNotificationsActive + ? "rgba(0, 0, 139, 0.24)" + : "divider", + bgcolor: isNotificationsActive + ? "rgba(0, 0, 139, 0.08)" + : "rgba(255, 255, 255, 0.76)", + "&:hover": { + bgcolor: isNotificationsActive + ? "rgba(0, 0, 139, 0.14)" + : "rgba(15, 23, 42, 0.04)", + }, + }} + > + + @@ -285,25 +313,70 @@ export const Topbar = ({ onClose={handleMenuClose} transformOrigin={{ horizontal: "right", vertical: "top" }} anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + slotProps={{ + paper: { + sx: { + mt: 1, + minWidth: 280, + borderRadius: 3, + border: "1px solid", + borderColor: "divider", + boxShadow: "0 18px 44px rgba(15, 23, 42, 0.14)", + }, + }, + }} + MenuListProps={{ + dense: true, + sx: { + py: 0, + }, + }} > - + - Role: - - + + {fullName} + + + {email} + + - Settings + + Settings + - + { + navigate({ to: "/notifications", search: {} }); + handleMenuClose(); + }} + sx={{ minHeight: 36, gap: 1, px: 1.5 }} + > + + + + + Notifications + + + { fullSignOut(); handleMenuClose(); }} + sx={{ minHeight: 36, gap: 1, px: 1.5 }} > - Logout + + Log out + diff --git a/frontend/src/components/TopbarUserButton.tsx b/frontend/src/components/TopbarUserButton.tsx index 5b08e57a..a7cf2971 100644 --- a/frontend/src/components/TopbarUserButton.tsx +++ b/frontend/src/components/TopbarUserButton.tsx @@ -1,6 +1,6 @@ import { ButtonProps, IconButton } from "@mui/material"; import { getRoleColor } from "@/utils"; -import { UserAvatar } from "./UserAvatar"; +import { UserAvatar } from "@/components/UserAvatar"; export const TopbarUserButton = ({ full_name, @@ -20,8 +20,8 @@ export const TopbarUserButton = ({ color={buttonColor} sx={{ ...buttonProps.sx, - width: { xs: 35, md: 40 }, - height: { xs: 35, md: 40 }, + width: { xs: 35, md: 40, lg: 44 }, + height: { xs: 35, md: 40, lg: 44 }, bgcolor: buttonColor, "&:hover": { bgcolor: buttonColor, diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 00f0066e..46dcbf11 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WorkordersRouteImport } from './routes/workorders' import { Route as SettingsRouteImport } from './routes/settings' +import { Route as NotificationsRouteImport } from './routes/notifications' import { Route as MonitoringwellsRouteImport } from './routes/monitoringwells' import { Route as LoginRouteImport } from './routes/login' import { Route as ChloridesRouteImport } from './routes/chlorides' @@ -41,6 +42,11 @@ const SettingsRoute = SettingsRouteImport.update({ path: '/settings', getParentRoute: () => rootRouteImport, } as any) +const NotificationsRoute = NotificationsRouteImport.update({ + id: '/notifications', + path: '/notifications', + getParentRoute: () => rootRouteImport, +} as any) const MonitoringwellsRoute = MonitoringwellsRouteImport.update({ id: '/monitoringwells', path: '/monitoringwells', @@ -144,6 +150,7 @@ export interface FileRoutesByFullPath { '/chlorides': typeof ChloridesRoute '/login': typeof LoginRoute '/monitoringwells': typeof MonitoringwellsRoute + '/notifications': typeof NotificationsRoute '/settings': typeof SettingsRoute '/workorders': typeof WorkordersRoute '/internal/error-preview': typeof InternalErrorPreviewRoute @@ -167,6 +174,7 @@ export interface FileRoutesByTo { '/chlorides': typeof ChloridesRoute '/login': typeof LoginRoute '/monitoringwells': typeof MonitoringwellsRoute + '/notifications': typeof NotificationsRoute '/settings': typeof SettingsRoute '/workorders': typeof WorkordersRoute '/internal/error-preview': typeof InternalErrorPreviewRoute @@ -190,6 +198,7 @@ export interface FileRoutesById { '/chlorides': typeof ChloridesRoute '/login': typeof LoginRoute '/monitoringwells': typeof MonitoringwellsRoute + '/notifications': typeof NotificationsRoute '/settings': typeof SettingsRoute '/workorders': typeof WorkordersRoute '/internal/error-preview': typeof InternalErrorPreviewRoute @@ -215,6 +224,7 @@ export interface FileRouteTypes { | '/chlorides' | '/login' | '/monitoringwells' + | '/notifications' | '/settings' | '/workorders' | '/internal/error-preview' @@ -238,6 +248,7 @@ export interface FileRouteTypes { | '/chlorides' | '/login' | '/monitoringwells' + | '/notifications' | '/settings' | '/workorders' | '/internal/error-preview' @@ -260,6 +271,7 @@ export interface FileRouteTypes { | '/chlorides' | '/login' | '/monitoringwells' + | '/notifications' | '/settings' | '/workorders' | '/internal/error-preview' @@ -284,6 +296,7 @@ export interface RootRouteChildren { ChloridesRoute: typeof ChloridesRoute LoginRoute: typeof LoginRoute MonitoringwellsRoute: typeof MonitoringwellsRoute + NotificationsRoute: typeof NotificationsRoute SettingsRoute: typeof SettingsRoute WorkordersRoute: typeof WorkordersRoute InternalErrorPreviewRoute: typeof InternalErrorPreviewRoute @@ -315,6 +328,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } + '/notifications': { + id: '/notifications' + path: '/notifications' + fullPath: '/notifications' + preLoaderRoute: typeof NotificationsRouteImport + parentRoute: typeof rootRouteImport + } '/monitoringwells': { id: '/monitoringwells' path: '/monitoringwells' @@ -484,6 +504,7 @@ const rootRouteChildren: RootRouteChildren = { ChloridesRoute: ChloridesRoute, LoginRoute: LoginRoute, MonitoringwellsRoute: MonitoringwellsRoute, + NotificationsRoute: NotificationsRoute, SettingsRoute: SettingsRoute, WorkordersRoute: WorkordersRoute, InternalErrorPreviewRoute: InternalErrorPreviewRoute, diff --git a/frontend/src/routes/notifications.tsx b/frontend/src/routes/notifications.tsx new file mode 100644 index 00000000..e7783046 --- /dev/null +++ b/frontend/src/routes/notifications.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Notifications } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/notifications")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx new file mode 100644 index 00000000..5af4280e --- /dev/null +++ b/frontend/src/views/Notifications.tsx @@ -0,0 +1,109 @@ +import { + Box, + Card, + CardContent, + Divider, + List, + ListItem, + ListItemIcon, + ListItemText, + Stack, + Typography, +} from "@mui/material"; +import NotificationsOutlinedIcon from "@mui/icons-material/NotificationsOutlined"; +import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; +import MarkEmailReadOutlinedIcon from "@mui/icons-material/MarkEmailReadOutlined"; +import { BackgroundBox, CustomCardHeader } from "@/components"; + +const notificationItems = [ + { + title: "No new alerts right now", + description: + "System notifications will appear here when new activity needs your attention.", + icon: NotificationsOutlinedIcon, + }, + { + title: "Profile and account changes", + description: + "Updates related to your account preferences and settings will be listed here.", + icon: SettingsOutlinedIcon, + }, + { + title: "Read status support", + description: + "This page is ready for unread and read notification states when those are added.", + icon: MarkEmailReadOutlinedIcon, + }, +] as const; + +export const Notifications = () => { + return ( + + + + + + + + Notification Center + + + There are no live notifications connected yet. This page gives + the topbar menu and bell button a dedicated destination. + + + + + + {notificationItems.map((item, index) => { + const Icon = item.icon; + + return ( + + + + + + + {item.title} + + } + secondary={ + + {item.description} + + } + /> + + {index < notificationItems.length - 1 ? : null} + + ); + })} + + + + + + + ); +}; diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index 035a9693..a28c6b86 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -5,6 +5,7 @@ export * from "./InsufficientPermView"; export * from "./Login"; export * from "./Meters"; export * from "./NotFound"; +export * from "./Notifications"; export * from "./Parts"; export * from "./Reports"; export * from "./RouteErrorView"; From ea2448fae609a6c45d3746ef4a3113c2ea7aac92 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Mar 2026 23:21:43 -0500 Subject: [PATCH 76/91] feat(Reports): Update pgs to have breadcrumbs --- frontend/src/components/CustomCardHeader.tsx | 2 +- .../src/components/ReportBreadcrumbTitle.tsx | 56 +++++++++++++++++++ frontend/src/components/Topbar.tsx | 7 ++- frontend/src/components/index.ts | 1 + frontend/src/constants.ts | 28 ++++++---- frontend/src/sidenav.tsx | 4 +- .../src/views/Reports/Chlorides/index.tsx | 12 +++- .../src/views/Reports/Maintenance/index.tsx | 12 +++- .../views/Reports/MonitoringWells/index.tsx | 12 +++- .../src/views/Reports/PartsUsed/index.tsx | 12 +++- frontend/src/views/Reports/index.tsx | 28 +++++----- 11 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/ReportBreadcrumbTitle.tsx diff --git a/frontend/src/components/CustomCardHeader.tsx b/frontend/src/components/CustomCardHeader.tsx index 32efd436..24667416 100644 --- a/frontend/src/components/CustomCardHeader.tsx +++ b/frontend/src/components/CustomCardHeader.tsx @@ -8,7 +8,7 @@ import { } from "@mui/material"; type CustomCardHeaderProps = Omit & { - title?: string; + title?: React.ReactNode; icon?: React.ComponentType; }; diff --git a/frontend/src/components/ReportBreadcrumbTitle.tsx b/frontend/src/components/ReportBreadcrumbTitle.tsx new file mode 100644 index 00000000..58e4b14c --- /dev/null +++ b/frontend/src/components/ReportBreadcrumbTitle.tsx @@ -0,0 +1,56 @@ +import AssessmentOutlinedIcon from "@mui/icons-material/AssessmentOutlined"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import { Box, Breadcrumbs, Link as MuiLink, Typography } from "@mui/material"; +import { Link as RouterLink } from "@tanstack/react-router"; + +export const ReportBreadcrumbTitle = ({ current }: { current: string }) => { + return ( + } + sx={{ + color: "inherit", + "& .MuiBreadcrumbs-ol": { + alignItems: "center", + }, + "& .MuiBreadcrumbs-separator": { + display: "inline-flex", + alignItems: "center", + color: "rgba(255, 255, 255, 0.72)", + mx: 1, + }, + }} + > + + + Reports + + + {current} + + + ); +}; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index c21e7913..558e667d 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -407,7 +407,12 @@ export const Topbar = ({ Notifications - + { fullSignOut(); diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 0bdf2c95..d5bff533 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -22,6 +22,7 @@ export * from "./ModalBackgroundBox"; export * from "./Modals"; export * from "./NavLink"; export * from "./ReportsNavItem"; +export * from "./ReportBreadcrumbTitle"; export * from "./RHControlled"; export * from "./ResizableSplitPanels"; export * from "./RoleChip"; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 65b29d29..18e497ec 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,6 +1,5 @@ import { FormatListBulletedOutlined, - ScreenshotMonitor, Construction, MonitorHeart, Plumbing, @@ -8,6 +7,11 @@ import { Science, People, Storage, + MonitorHeartOutlined, + ConstructionOutlined, + BuildOutlined, + ScienceOutlined, + WaterDrop, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; @@ -43,7 +47,7 @@ export const navConfig: NavItem[] = [ { path: "/manage/meters", label: "Manage Meters", - icon: ScreenshotMonitor, + icon: WaterDrop, role: "Technician", }, { @@ -54,31 +58,31 @@ export const navConfig: NavItem[] = [ }, // Reports + { + path: "/reports/chlorides", + label: "Chlorides", + icon: ScienceOutlined, + role: "Technician", + parent: "reports", + }, { path: "/reports/monitoringwells", label: "Monitoring Wells", - icon: MonitorHeart, + icon: MonitorHeartOutlined, role: "Technician", parent: "reports", }, { path: "/reports/maintenance", label: "Maintenance", - icon: Construction, + icon: ConstructionOutlined, role: "Technician", parent: "reports", }, { path: "/reports/partsused", label: "Parts Used", - icon: Build, - role: "Technician", - parent: "reports", - }, - { - path: "/reports/chlorides", - label: "Chlorides", - icon: Science, + icon: BuildOutlined, role: "Technician", parent: "reports", }, diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index eafc826c..6dc1d0d9 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -12,7 +12,7 @@ import { import { SvgIconProps } from "@mui/material/SvgIcon"; import { useNavigate } from "@tanstack/react-router"; import { - Assessment, + AssessmentOutlined, ExpandLess, ExpandMore, TableView, @@ -175,7 +175,7 @@ function ReportsSidebarButton({ )} } - icon={Assessment} + icon={AssessmentOutlined} /> ); } diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 3fe6c120..84e9698b 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,5 +1,9 @@ import { useEffect, useMemo } from "react"; -import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; +import { + ArrowBack, + PictureAsPdf, + ScienceOutlined, +} from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; import { useAuthHeader } from "react-auth-kit"; @@ -36,6 +40,7 @@ import { BackgroundBox, DirectionCard, MapUrlStateSync, + ReportBreadcrumbTitle, SoutheastGuideLayer, SatelliteLayer, OpenStreetMapLayer, @@ -237,7 +242,10 @@ export const ChloridesReportView = () => { return ( - + } + icon={ScienceOutlined} + /> { return ( - + } + icon={ConstructionOutlined} + /> diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 551bcc70..9db7b86d 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -1,6 +1,10 @@ /** @jsxImportSource @emotion/react */ import { useMemo, useEffect, useRef } from "react"; -import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; +import { + ArrowBack, + PictureAsPdf, + MonitorHeartOutlined, +} from "@mui/icons-material"; import { Box, Button, @@ -38,6 +42,7 @@ import { ControlledAutocomplete, BackgroundBox, CustomCardHeader, + ReportBreadcrumbTitle, } from "@/components"; import { MonitoredWell, WellMeasurementDTO } from "@/interfaces"; import { ReportAveragesResponse } from "@/interfaces/ReportAveragesResponse"; @@ -545,7 +550,10 @@ export const MonitoringWellsReportView = () => { return ( - + } + icon={MonitorHeartOutlined} + /> diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index ffba4e6b..959f84c1 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -1,6 +1,10 @@ import { useEffect, useMemo, useRef } from "react"; import { useAuthHeader } from "react-auth-kit"; -import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; +import { + ArrowBack, + BuildOutlined, + PictureAsPdf, +} from "@mui/icons-material"; import { Autocomplete, Button, @@ -27,6 +31,7 @@ import { BackgroundBox, CustomCardHeader, ControlledSelect, + ReportBreadcrumbTitle, } from "@/components"; import { Route } from "@/routes/reports/partsused"; @@ -396,7 +401,10 @@ export const PartsUsedReportView = () => { return ( - + } + icon={BuildOutlined} + /> { return ( - + + - From 736e18f1f231e1425fd20d94e7b2ffbd1ae9dc31 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Mar 2026 23:43:18 -0500 Subject: [PATCH 77/91] feat(Manage): Add manage page --- .../src/components/ManageBreadcrumbTitle.tsx | 58 +++++++++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/constants.ts | 14 ++--- frontend/src/routeTree.gen.ts | 21 +++++++ frontend/src/routes/manage/index.tsx | 11 ++++ frontend/src/routes/monitoringwells.tsx | 8 ++- frontend/src/routes/workorders.tsx | 4 +- .../src/views/Activities/ActivitiesView.tsx | 4 +- frontend/src/views/Backups/BackupsView.tsx | 11 +++- frontend/src/views/Manage/ManageView.tsx | 53 +++++++++++++++++ frontend/src/views/Manage/index.ts | 1 + .../Meters/MeterSelection/MeterSelection.tsx | 9 ++- frontend/src/views/Parts/MeterTypesTable.tsx | 11 ++-- frontend/src/views/Parts/PartsTable.tsx | 8 ++- .../views/UserManagement/PermissionsTable.tsx | 7 +-- .../src/views/UserManagement/RolesTable.tsx | 42 +++++++------- .../src/views/UserManagement/UsersTable.tsx | 8 ++- .../src/views/WellManagement/WellsTable.tsx | 9 ++- frontend/src/views/index.ts | 1 + 19 files changed, 223 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/ManageBreadcrumbTitle.tsx create mode 100644 frontend/src/routes/manage/index.tsx create mode 100644 frontend/src/views/Manage/ManageView.tsx create mode 100644 frontend/src/views/Manage/index.ts diff --git a/frontend/src/components/ManageBreadcrumbTitle.tsx b/frontend/src/components/ManageBreadcrumbTitle.tsx new file mode 100644 index 00000000..05304fc2 --- /dev/null +++ b/frontend/src/components/ManageBreadcrumbTitle.tsx @@ -0,0 +1,58 @@ +import DashboardCustomizeOutlinedIcon from "@mui/icons-material/DashboardCustomizeOutlined"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import { Box, Breadcrumbs, Link as MuiLink, Typography } from "@mui/material"; +import { Link as RouterLink } from "@tanstack/react-router"; + +export const ManageBreadcrumbTitle = ({ current }: { current: string }) => { + return ( + } + sx={{ + color: "inherit", + "& .MuiBreadcrumbs-ol": { + alignItems: "center", + }, + "& .MuiBreadcrumbs-separator": { + display: "inline-flex", + alignItems: "center", + color: "rgba(255, 255, 255, 0.72)", + mx: 1, + }, + }} + > + + + Manage + + + {current} + + + ); +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index d5bff533..42b9dab1 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -12,6 +12,7 @@ export * from "./ImageUploadWithPreview"; export * from "./IsTrueChip"; export * from "./Layers"; export * from "./LinkBehavior"; +export * from "./ManageBreadcrumbTitle"; export * from "./MapUrlStateSync"; export * from "./MergeWellModal"; export * from "./MeterMapColorLegend"; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 18e497ec..410b1916 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -1,8 +1,6 @@ import { - FormatListBulletedOutlined, - Construction, + AssignmentTurnedInOutlined, MonitorHeart, - Plumbing, Build, Science, People, @@ -12,6 +10,8 @@ import { BuildOutlined, ScienceOutlined, WaterDrop, + SpeedOutlined, + Engineering, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; @@ -35,25 +35,25 @@ export const navConfig: NavItem[] = [ { path: "/workorders", label: "Work Orders", - icon: FormatListBulletedOutlined, + icon: AssignmentTurnedInOutlined, role: "Technician", }, { path: "/activities", label: "Activities", - icon: Construction, + icon: Engineering, role: "Technician", }, { path: "/manage/meters", label: "Manage Meters", - icon: WaterDrop, + icon: SpeedOutlined, role: "Technician", }, { path: "/manage/wells", label: "Manage Wells", - icon: Plumbing, + icon: WaterDrop, role: "Technician", }, diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 46dcbf11..8e4d6621 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ChloridesRouteImport } from './routes/chlorides' import { Route as ActivitiesRouteImport } from './routes/activities' import { Route as IndexRouteImport } from './routes/index' import { Route as ReportsIndexRouteImport } from './routes/reports/index' +import { Route as ManageIndexRouteImport } from './routes/manage/index' import { Route as ReportsPartsusedRouteImport } from './routes/reports/partsused' import { Route as ReportsMonitoringwellsRouteImport } from './routes/reports/monitoringwells' import { Route as ReportsMaintenanceRouteImport } from './routes/reports/maintenance' @@ -77,6 +78,11 @@ const ReportsIndexRoute = ReportsIndexRouteImport.update({ path: '/reports/', getParentRoute: () => rootRouteImport, } as any) +const ManageIndexRoute = ManageIndexRouteImport.update({ + id: '/manage/', + path: '/manage/', + getParentRoute: () => rootRouteImport, +} as any) const ReportsPartsusedRoute = ReportsPartsusedRouteImport.update({ id: '/reports/partsused', path: '/reports/partsused', @@ -163,6 +169,7 @@ export interface FileRoutesByFullPath { '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute + '/manage/': typeof ManageIndexRoute '/reports/': typeof ReportsIndexRoute '/manage/parts/': typeof ManagePartsIndexRoute '/activities/$activity_id/photos/$photo_file_name': typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute @@ -186,6 +193,7 @@ export interface FileRoutesByTo { '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute + '/manage': typeof ManageIndexRoute '/reports': typeof ReportsIndexRoute '/manage/parts': typeof ManagePartsIndexRoute '/activities/$activity_id/photos/$photo_file_name': typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute @@ -211,6 +219,7 @@ export interface FileRoutesById { '/reports/maintenance': typeof ReportsMaintenanceRoute '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute + '/manage/': typeof ManageIndexRoute '/reports/': typeof ReportsIndexRoute '/manage/parts/': typeof ManagePartsIndexRoute '/activities/$activity_id/photos/$photo_file_name': typeof ActivitiesActivity_idPhotosPhoto_file_nameRoute @@ -237,6 +246,7 @@ export interface FileRouteTypes { | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/manage/' | '/reports/' | '/manage/parts/' | '/activities/$activity_id/photos/$photo_file_name' @@ -260,6 +270,7 @@ export interface FileRouteTypes { | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/manage' | '/reports' | '/manage/parts' | '/activities/$activity_id/photos/$photo_file_name' @@ -284,6 +295,7 @@ export interface FileRouteTypes { | '/reports/maintenance' | '/reports/monitoringwells' | '/reports/partsused' + | '/manage/' | '/reports/' | '/manage/parts/' | '/activities/$activity_id/photos/$photo_file_name' @@ -309,6 +321,7 @@ export interface RootRouteChildren { ReportsMaintenanceRoute: typeof ReportsMaintenanceRoute ReportsMonitoringwellsRoute: typeof ReportsMonitoringwellsRoute ReportsPartsusedRoute: typeof ReportsPartsusedRoute + ManageIndexRoute: typeof ManageIndexRoute ReportsIndexRoute: typeof ReportsIndexRoute } @@ -377,6 +390,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ReportsIndexRouteImport parentRoute: typeof rootRouteImport } + '/manage/': { + id: '/manage/' + path: '/manage' + fullPath: '/manage/' + preLoaderRoute: typeof ManageIndexRouteImport + parentRoute: typeof rootRouteImport + } '/reports/partsused': { id: '/reports/partsused' path: '/reports/partsused' @@ -517,6 +537,7 @@ const rootRouteChildren: RootRouteChildren = { ReportsMaintenanceRoute: ReportsMaintenanceRoute, ReportsMonitoringwellsRoute: ReportsMonitoringwellsRoute, ReportsPartsusedRoute: ReportsPartsusedRoute, + ManageIndexRoute: ManageIndexRoute, ReportsIndexRoute: ReportsIndexRoute, } export const routeTree = rootRouteImport diff --git a/frontend/src/routes/manage/index.tsx b/frontend/src/routes/manage/index.tsx new file mode 100644 index 00000000..9abb808a --- /dev/null +++ b/frontend/src/routes/manage/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ManageView } from "@/views"; +import { ProtectedRoute } from "@/ProtectedRoute"; + +export const Route = createFileRoute("/manage/")({ + component: () => ( + + + + ), +}); diff --git a/frontend/src/routes/monitoringwells.tsx b/frontend/src/routes/monitoringwells.tsx index 0e516cad..53c5b4e3 100644 --- a/frontend/src/routes/monitoringwells.tsx +++ b/frontend/src/routes/monitoringwells.tsx @@ -101,7 +101,11 @@ function MonitoringWells() { } const parsedSplit = Number(storedSplit); - if (!Number.isInteger(parsedSplit) || parsedSplit < 35 || parsedSplit > 72) { + if ( + !Number.isInteger(parsedSplit) || + parsedSplit < 35 || + parsedSplit > 72 + ) { return; } @@ -277,7 +281,7 @@ function MonitoringWells() { return ( - + {error && ( diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index 2e6caf27..5e3cb2bd 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,13 +1,13 @@ import { CardContent, Card } from "@mui/material"; import MeterActivityEntry from "./MeterActivityEntry/MeterActivityEntry"; -import { Construction } from "@mui/icons-material"; +import { Engineering } from "@mui/icons-material"; import { BackgroundBox, CustomCardHeader } from "@/components"; export const ActivitiesView = () => { return ( - + diff --git a/frontend/src/views/Backups/BackupsView.tsx b/frontend/src/views/Backups/BackupsView.tsx index 4b2eda73..602341fc 100644 --- a/frontend/src/views/Backups/BackupsView.tsx +++ b/frontend/src/views/Backups/BackupsView.tsx @@ -16,7 +16,11 @@ import { Route } from "@/routes/manage/backups"; import { useNavigate } from "@tanstack/react-router"; import { BackupRow } from "@/interfaces/BackupRow"; import { useFetchWithAuth } from "@/hooks"; -import { BackgroundBox, CustomCardHeader } from "@/components"; +import { + BackgroundBox, + CustomCardHeader, + ManageBreadcrumbTitle, +} from "@/components"; import { toYYYYMMDD, formatBytes } from "@/utils"; export const BackupsView = () => { @@ -142,7 +146,10 @@ export const BackupsView = () => { return ( - + } + icon={Storage} + /> {error && ( { + const authUser = useAuthUser(); + const scopes = useMemo( + () => + new Set( + authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string, + ) ?? [], + ), + [authUser], + ); + + const hasReadScope = scopes.has("read"); + const hasAdminScope = scopes.has("admin"); + + const manageItems = navConfig.filter((item) => { + if (!item.path.startsWith("/manage/")) return false; + if (item.role === "Technician") return hasReadScope; + if (item.role === "Admin") return hasAdminScope; + return true; + }); + + return ( + + + + + + {manageItems.map((item) => ( + + ))} + + + + + ); +}; diff --git a/frontend/src/views/Manage/index.ts b/frontend/src/views/Manage/index.ts new file mode 100644 index 00000000..8c7ae438 --- /dev/null +++ b/frontend/src/views/Manage/index.ts @@ -0,0 +1 @@ +export * from "./ManageView"; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index 9e2c4513..6f754556 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -11,9 +11,9 @@ import { ToggleButton, InputAdornment, } from "@mui/material"; -import { FormatListBulletedOutlined, Search } from "@mui/icons-material"; +import { Search, SpeedOutlined } from "@mui/icons-material"; import { MeterStatusNames } from "@/enums"; -import { CustomCardHeader, TabPanel } from "@/components"; +import { CustomCardHeader, ManageBreadcrumbTitle, TabPanel } from "@/components"; import { useMemo } from "react"; type MeterFilterKey = "installed" | "stored" | "sold" | "scrapped" | "unknown"; @@ -63,7 +63,10 @@ export const MeterSelection = ({ return ( - + } + icon={SpeedOutlined} + /> diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index 521605b0..1c461952 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -10,7 +10,7 @@ import { TextField, Typography, } from "@mui/material"; -import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { Search, Add, SpeedOutlined } from "@mui/icons-material"; import { useNavigate } from "@tanstack/react-router"; import { useGetMeterTypeList } from "@/service"; import { Route } from "@/routes/manage/parts/index"; @@ -18,6 +18,7 @@ import { CustomCardHeader, GridFooterWithButton, IsTrueChip, + ManageBreadcrumbTitle, TristateToggle, } from "@/components"; @@ -78,10 +79,7 @@ export const MeterTypesTable = ({ return ( - + ({ ...prev, mt_pageSize: model.pageSize, - mt_page: - model.pageSize !== prev.mt_pageSize ? 0 : model.page, + mt_page: model.pageSize !== prev.mt_pageSize ? 0 : model.page, })) } pageSizeOptions={[10, 25, 50, 100]} diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 1b3e1153..4c3d6158 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -17,8 +17,8 @@ import { PlusOne, Search, Add, - FormatListBulletedOutlined, History, + Build, } from "@mui/icons-material"; import { useSnackbar } from "notistack"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -29,6 +29,7 @@ import { GridFooterWithButton, IncreaseQuantityModal, IsTrueChip, + ManageBreadcrumbTitle, TristateToggle, } from "@/components"; @@ -145,7 +146,10 @@ export const PartsTable = ({ return ( - + } + icon={Build} + /> { return ( - + diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 6b366204..ccb4197e 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -9,7 +9,7 @@ import { InputAdornment, TextField, } from "@mui/material"; -import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { Search, Add, AdminPanelSettingsOutlined } from "@mui/icons-material"; import { useNavigate } from "@tanstack/react-router"; import { useGetRoles } from "@/service"; import { Route } from "@/routes/manage/users"; @@ -71,7 +71,7 @@ export const RolesTable = ({ return ( - + - - setSearch((prev) => ({ - ...prev, - r_pageSize: model.pageSize, - r_page: model.pageSize !== prev.r_pageSize ? 0 : model.page, - })) - } - pageSizeOptions={[10, 25, 50, 100]} - rowSelectionModel={search.role_id ? [search.role_id] : []} - loading={rolesList.isLoading} - columns={cols} + + setSearch((prev) => ({ + ...prev, + r_pageSize: model.pageSize, + r_page: model.pageSize !== prev.r_pageSize ? 0 : model.page, + })) + } + pageSizeOptions={[10, 25, 50, 100]} + rowSelectionModel={search.role_id ? [search.role_id] : []} + loading={rolesList.isLoading} + columns={cols} disableColumnMenu onRowClick={(selectedRow) => onSelectRole(selectedRow.row.id)} slots={{ footer: GridFooterWithButton }} diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 3e67f532..e8e80e63 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -10,7 +10,7 @@ import { TextField, Typography, } from "@mui/material"; -import { Search, Add, FormatListBulletedOutlined } from "@mui/icons-material"; +import { Search, Add, People } from "@mui/icons-material"; import { useNavigate } from "@tanstack/react-router"; import { Route } from "@/routes/manage/users"; import { useGetUserAdminList } from "@/service"; @@ -18,6 +18,7 @@ import { CustomCardHeader, GridFooterWithButton, IsTrueChip, + ManageBreadcrumbTitle, RoleChip, TristateToggle, UserAvatar, @@ -116,7 +117,10 @@ export const UsersTable = ({ return ( - + } + icon={People} + /> { flexDirection: "column", }} > - + } + icon={WaterDrop} + /> diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts index a28c6b86..ec225209 100644 --- a/frontend/src/views/index.ts +++ b/frontend/src/views/index.ts @@ -3,6 +3,7 @@ export * from "./Backups"; export * from "./Home"; export * from "./InsufficientPermView"; export * from "./Login"; +export * from "./Manage"; export * from "./Meters"; export * from "./NotFound"; export * from "./Notifications"; From d8e7d0731f623f9892d4931339b42d588dc101ac Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 12 Mar 2026 23:51:19 -0500 Subject: [PATCH 78/91] feat(manage): Reselecting a row unselects it --- .../Meters/MeterSelection/MeterSelectionTable.tsx | 10 +++++++++- frontend/src/views/Parts/MeterTypesTable.tsx | 6 +++++- frontend/src/views/Parts/PartsTable.tsx | 5 +++++ .../src/views/UserManagement/PermissionsTable.tsx | 1 + frontend/src/views/UserManagement/RolesTable.tsx | 9 ++++++++- frontend/src/views/UserManagement/UsersTable.tsx | 9 ++++++++- .../src/views/WellManagement/WellSelectionTable.tsx | 13 +++++++++++++ 7 files changed, 49 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 8fc9ac20..a54008a5 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -114,7 +114,15 @@ export const MeterSelectionTable = ({ rows={meterList.data?.items ?? []} loading={meterList.isPreviousData || meterList.isLoading} columns={meterTableColumns} - onRowClick={(selectedRow) => onMeterSelection(selectedRow.row.id)} + rowSelectionModel={search.meter_id ? [search.meter_id] : []} + onRowClick={(selectedRow) => { + if (search.meter_id === selectedRow.row.id) { + setMeterAddMode(true); + return; + } + + onMeterSelection(selectedRow.row.id); + }} keepNonExistentRowsSelected sortingMode="server" sortModel={gridSortModel} diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index 1c461952..2766b630 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -18,7 +18,6 @@ import { CustomCardHeader, GridFooterWithButton, IsTrueChip, - ManageBreadcrumbTitle, TristateToggle, } from "@/components"; @@ -164,6 +163,11 @@ export const MeterTypesTable = ({ columns={cols} disableColumnMenu onRowClick={(selectedRow) => { + if (search.meter_type_id === selectedRow.row.id) { + onCreateMeterType(); + return; + } + onSelectMeterType(selectedRow.row.id); }} slots={{ footer: GridFooterWithButton }} diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 4c3d6158..a470a848 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -243,6 +243,11 @@ export const PartsTable = ({ columns={cols} disableColumnMenu onRowClick={(selectedRow) => { + if (search.part_id === selectedRow.row.id) { + onCreatePart(); + return; + } + onSelectPart(selectedRow.row.id); }} slots={{ footer: GridFooterWithButton }} diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 21ebe021..1fc5c553 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -29,6 +29,7 @@ export const PermissionsTable = () => { columns={cols} disableColumnMenu disableColumnFilter + disableRowSelectionOnClick hideFooter /> diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index ccb4197e..194f7db7 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -126,7 +126,14 @@ export const RolesTable = ({ loading={rolesList.isLoading} columns={cols} disableColumnMenu - onRowClick={(selectedRow) => onSelectRole(selectedRow.row.id)} + onRowClick={(selectedRow) => { + if (search.role_id === selectedRow.row.id) { + onCreateRole(); + return; + } + + onSelectRole(selectedRow.row.id); + }} slots={{ footer: GridFooterWithButton }} slotProps={{ footer: { diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index e8e80e63..a6f564a4 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -213,7 +213,14 @@ export const UsersTable = ({ loading={usersList.isLoading} columns={cols} disableColumnMenu - onRowClick={(r) => onSelectUser(r.row.id)} + onRowClick={(r) => { + if (search.user_id === r.row.id) { + onCreateUser(); + return; + } + + onSelectUser(r.row.id); + }} slots={{ footer: GridFooterWithButton }} slotProps={{ footer: { diff --git a/frontend/src/views/WellManagement/WellSelectionTable.tsx b/frontend/src/views/WellManagement/WellSelectionTable.tsx index fa4c0e7c..69323c14 100644 --- a/frontend/src/views/WellManagement/WellSelectionTable.tsx +++ b/frontend/src/views/WellManagement/WellSelectionTable.tsx @@ -144,6 +144,19 @@ export default function WellSelectionTable({ disableColumnMenu keepNonExistentRowsSelected onRowClick={(selectedRow) => { + if (search.well_id === selectedRow.row.id) { + navigate({ + to: "/manage/wells", + search: (prev) => ({ + ...(prev as any), + add: true, + well_id: undefined, + }), + replace: true, + }); + return; + } + const well = wellsList.data?.items.find( (well: Well) => well.id == selectedRow.row.id, ); From b2965d4e2576dda4592ef74aaf812a52cd5dc704 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 11:06:23 -0500 Subject: [PATCH 79/91] feat(Reports|Topbar): Update styles in reports & disable notification links --- frontend/src/components/Topbar.tsx | 2 + .../src/views/Reports/Chlorides/index.tsx | 71 ++++++++--------- .../src/views/Reports/Maintenance/index.tsx | 68 ++++++++-------- .../views/Reports/MonitoringWells/index.tsx | 66 ++++++++-------- .../src/views/Reports/PartsUsed/index.tsx | 77 +++++++++---------- 5 files changed, 138 insertions(+), 146 deletions(-) diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 558e667d..33c1badf 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -279,6 +279,7 @@ export const Topbar = ({ {isLoggedIn ? ( navigate({ to: "/notifications", search: {} })} sx={{ @@ -394,6 +395,7 @@ export const Topbar = ({ { navigate({ to: "/notifications", search: {} }); handleMenuClose(); diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 84e9698b..21c3cb45 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,9 +1,5 @@ import { useEffect, useMemo } from "react"; -import { - ArrowBack, - PictureAsPdf, - ScienceOutlined, -} from "@mui/icons-material"; +import { PictureAsPdf, ScienceOutlined } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; import { useAuthHeader } from "react-auth-kit"; @@ -12,7 +8,6 @@ import { Card, CardContent, Grid, - IconButton, Tooltip, Typography, Alert, @@ -27,7 +22,7 @@ import { Marker, Tooltip as MapTooltip, } from "react-leaflet"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -247,40 +242,12 @@ export const ChloridesReportView = () => { icon={ScienceOutlined} /> - - - - - - - - - - - - - - - - - - { format="YYYY MMMM DD" /> + + + + + + + { - + + + + { - + { rowCount={dataQuery.data?.total ?? tableRows.length} /> - + + + + { /> - + { - + { }} /> - + Report Averages ({bucketLabel}) @@ -937,7 +933,7 @@ export const MonitoringWellsReportView = () => { )} - + + + + { } /> - + - - { }} /> - - - {hasChanges && } + + + From 9de3f458c2dd21dd9d5db46bee84e7885690815f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 12:57:32 -0500 Subject: [PATCH 82/91] feat(PartsHistory): Add go back and select same id in breadcrumbs --- frontend/src/views/Parts/PartsHistory.tsx | 63 ++++++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/frontend/src/views/Parts/PartsHistory.tsx b/frontend/src/views/Parts/PartsHistory.tsx index f36b5713..47980660 100644 --- a/frontend/src/views/Parts/PartsHistory.tsx +++ b/frontend/src/views/Parts/PartsHistory.tsx @@ -22,6 +22,7 @@ import { Build, DashboardCustomizeOutlined, History, + InfoOutlined, NavigateNext, PlusOne, Save, @@ -151,7 +152,13 @@ function hydrateRows(data: PartHistoryResponse, partId?: string) { return recalculateRows(currentRow ? [...raw, currentRow] : raw); } -const PartsHistoryBreadcrumbTitle = () => { +const PartsHistoryBreadcrumbTitle = ({ + partNumber = "", + partId, +}: { + partId?: string; + partNumber?: string; +}) => { return ( { Parts + {partNumber && partId && ( + + + {partNumber} + + )} { } + title={ + + } icon={History} /> - + { format="YYYY MMMM DD" /> - + { format="YYYY MMMM DD" /> - + { } /> - + { page: 0, })) } - sx={{ width: { xs: "100%", md: 360 } }} + sx={{ width: "100%" }} InputProps={{ startAdornment: ( From 3e40836adfd0b738b0cc4e3f80c8b9a8c3a335e2 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 13:25:46 -0500 Subject: [PATCH 83/91] feat(export_meter_readings): Add well depth info --- scripts/export_meter_readings.sql | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/export_meter_readings.sql b/scripts/export_meter_readings.sql index d3e7a7ee..0a05fae8 100644 --- a/scripts/export_meter_readings.sql +++ b/scripts/export_meter_readings.sql @@ -14,6 +14,7 @@ SELECT "Well Name", "RA Number", + "Well Depth (ft)", "Meter Reading Date", "Meter Reading Value", "Meter Reading Unit", @@ -25,17 +26,18 @@ SELECT "Meter ID" FROM ( SELECT - l.name AS "Location Name", - w.name AS "Well Name", - w.ra_number AS "RA Number", - to_char(mo."timestamp"::date, 'YYYY-MM-DD') AS "Meter Reading Date", - opt.name AS "Parameter", - mo.value AS "Meter Reading Value", - u.name_short AS "Meter Reading Unit", - l.latitude AS "Latitude", - l.longitude AS "Longitude", - ST_AsText(l.geom) AS "Location Geometry (WKT)", - mo.meter_id AS "Meter ID" + l.name AS "Location Name", + w.name AS "Well Name", + w.ra_number AS "RA Number", + w.total_depth AS "Well Depth (ft)", + to_char(mo."timestamp"::date, 'YYYY-MM-DD') AS "Meter Reading Date", + opt.name AS "Parameter", + mo.value AS "Meter Reading Value", + u.name_short AS "Meter Reading Unit", + l.latitude AS "Latitude", + l.longitude AS "Longitude", + ST_AsText(l.geom) AS "Location Geometry (WKT)", + mo.meter_id AS "Meter ID" FROM public."MeterObservations" mo JOIN public."Units" u ON u.id = mo.unit_id From f3e2abc3012c24518561c359a0d20652ead29250 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 15:38:52 -0500 Subject: [PATCH 84/91] feat(WorkOrders): Add avatars to work order pg --- .../src/views/WorkOrders/WorkOrdersTable.tsx | 98 +++++++++++++++---- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 139bc60e..e79dd4b3 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -25,7 +25,7 @@ import { Stack, TextField, } from "@mui/material"; -import { GridFooterWithButton } from "@/components"; +import { GridFooterWithButton, UserAvatar } from "@/components"; import { Create } from "@/components/Modals/WorkOrders"; import { MeterActivity, NewWorkOrder, User } from "@/interfaces"; import { useSnackbar } from "notistack"; @@ -63,8 +63,14 @@ export const WorkOrdersTable = () => { return sortUsersByRoleThenName((userList.data ?? []) as User[]); }, [userList.data]); + const getUserByID = (id: number | undefined) => + userList.data?.find((u) => u.id === id); + + const getAvatarRole = (user: User | null | undefined) => + user ? getRoleLabel(user) : undefined; + const getUserFromID = (id: number | undefined) => - userList.data?.find((u) => u.id === id)?.full_name ?? ""; + getUserByID(id)?.full_name ?? ""; const getUserIDfromName = (name: string) => userList.data?.find((u) => u.full_name === name)?.id ?? undefined; @@ -321,7 +327,25 @@ export const WorkOrdersTable = () => { headerName: "Technician Assigned", flex: 2, minWidth: 200, + cellClassName: "work-order-top-cell", valueGetter: (id: number) => getUserFromID(id), + renderCell: (params) => { + const assignedUser = getUserByID(params.row.assigned_user_id); + + if (!assignedUser) return ""; + + return ( + + + {assignedUser.full_name} + + ); + }, type: "singleSelect", valueOptions: sortedUsers.map((user) => user.full_name), editable: hasAdminScope, @@ -354,6 +378,7 @@ export const WorkOrdersTable = () => { flex: 1, minWidth: 100, sortable: false, + cellClassName: "work-order-top-cell", renderCell: (params: GridRenderCellParams) => { const isOpen = params.row.status === "Open"; @@ -361,9 +386,8 @@ export const WorkOrdersTable = () => { {isOpen && ( @@ -408,6 +432,9 @@ export const WorkOrdersTable = () => { const rows = workOrderList.data ?? []; const loading = workOrderList.isLoading || workOrderList.isFetching; + const selectedAssignedUser = assigned_user_id + ? (sortedUsers.find((u) => u.id === assigned_user_id) ?? null) + : null; return ( @@ -444,11 +471,7 @@ export const WorkOrdersTable = () => { isOptionEqualToValue={(option: User, value: User) => option.id === value.id } - value={ - assigned_user_id - ? (sortedUsers.find((u) => u.id === assigned_user_id) ?? null) - : null - } + value={selectedAssignedUser} onChange={(_, user) => { const id = user?.id; setSearch((p) => ({ ...p, assigned_user_id: id, page: 0 })); @@ -456,19 +479,48 @@ export const WorkOrdersTable = () => { sx={{ minWidth: 260 }} renderOption={(props, option) => (
  • - {option.full_name} + + + {option.full_name} +
  • )} - renderInput={(params) => ( - - )} + renderInput={(params) => { + const { InputProps, ...rest } = params; + + return ( + + {selectedAssignedUser ? ( + + ) : null} + {InputProps.startAdornment} + + ), + }} + label={ + hasAdminScope + ? "Assigned technician" + : "Assigned (admin only)" + } + /> + ); + }} /> )} {
    "auto"} From 0c4fecb19d26731617c1487dbe00349d76d2ef38 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 15:57:39 -0500 Subject: [PATCH 85/91] feat(Reports): Add grouping by user role --- .../RHControlled/ControlledUserSelect.tsx | 104 ++++++++++++++++-- frontend/src/utils/UserRoleGrouping.ts | 15 ++- .../src/views/Reports/Maintenance/index.tsx | 77 ++++++++++++- .../src/views/Reports/PartsUsed/index.tsx | 16 ++- 4 files changed, 198 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledUserSelect.tsx b/frontend/src/components/RHControlled/ControlledUserSelect.tsx index 9b43cdbd..f377d4ec 100644 --- a/frontend/src/components/RHControlled/ControlledUserSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledUserSelect.tsx @@ -1,9 +1,17 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useAuthUser } from "react-auth-kit"; +import { Autocomplete, Box, Stack, TextField } from "@mui/material"; +import { Controller } from "react-hook-form"; import { User } from "@/interfaces"; import { useGetUserList } from "@/service"; +import { UserAvatar } from "@/components/UserAvatar"; +import { + getRoleLabel, + sortUsersByRoleThenName, +} from "@/utils/UserRoleGrouping"; -import { ControlledSelect } from "./ControlledSelect"; +const getAvatarRole = (user: User | null | undefined) => + user ? getRoleLabel(user) : undefined; export const ControlledUserSelect = ({ name, @@ -16,17 +24,93 @@ export const ControlledUserSelect = ({ if (!hideAndSelectCurrentUser) { const userList = useGetUserList(); + const users = useMemo( + () => sortUsersByRoleThenName(userList.data ?? []), + [userList.data], + ); + const { + label = "User", + error, + helperText, + disabled, + sx, + ...autocompleteProps + } = childProps; return ( - user.full_name} - label="User" - disabled={userList.isLoading} - {...childProps} - value={userList.isLoading ? "Loading..." : childProps.value} + control={control} + defaultValue={null} + render={({ field }) => ( + (() => { + const selectedUser = + users.find((user) => user.id === field.value?.id) ?? + field.value ?? + null; + + return ( + + {...autocompleteProps} + {...field} + size="small" + options={users} + groupBy={(user: User) => getRoleLabel(user)} + getOptionLabel={(user: User) => user?.full_name ?? ""} + isOptionEqualToValue={(option: User, value: User) => + option.id === value.id + } + value={selectedUser} + onChange={(_, newValue) => field.onChange(newValue)} + loading={userList.isLoading} + disabled={disabled ?? userList.isLoading} + sx={sx} + renderOption={(props, option) => ( + + + + {option.full_name} + + + )} + renderInput={(params) => { + const { InputProps, ...rest } = params; + + return ( + + {selectedUser ? ( + + ) : null} + {InputProps.startAdornment} + + ), + }} + /> + ); + }} + /> + ); + })() + )} /> ); } else { diff --git a/frontend/src/utils/UserRoleGrouping.ts b/frontend/src/utils/UserRoleGrouping.ts index be4a7b7a..495ee235 100644 --- a/frontend/src/utils/UserRoleGrouping.ts +++ b/frontend/src/utils/UserRoleGrouping.ts @@ -4,7 +4,8 @@ import { User } from "@/interfaces"; export type RoleLabel = "Admin" | "Technician" | "OSE" | "Unknown"; export const getRoleLabel = (user: User): RoleLabel => { - switch (user.user_role_id) { + const roleId = user.user_role_id ?? user.user_role?.id; + switch (roleId) { case ROLE_IDS.ADMIN: return "Admin"; case ROLE_IDS.TECHNICIAN: @@ -12,7 +13,17 @@ export const getRoleLabel = (user: User): RoleLabel => { case ROLE_IDS.OSE: return "OSE"; default: - return "Unknown"; + switch (user.user_role?.name?.toLowerCase()) { + case "admin": + return "Admin"; + case "technician": + case "tech": + return "Technician"; + case "ose": + return "OSE"; + default: + return "Unknown"; + } } }; diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 872409a5..de71c819 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -33,6 +33,7 @@ import { ControlledTextbox, CustomCardHeader, ReportBreadcrumbTitle, + UserAvatar, } from "@/components"; import { API_URL, ROLE_IDS } from "@/config"; import { User } from "@/interfaces"; @@ -281,6 +282,12 @@ export const MaintenanceReportView = () => { ); }, [dataQuery.data]); + const techniciansByName = useMemo(() => { + return new Map( + technicianOptions.map((user) => [user.full_name, user] as const), + ); + }, [technicianOptions]); + const columns: GridColDef[] = [ { field: "date_time", @@ -296,18 +303,59 @@ export const MaintenanceReportView = () => { return date.toLocaleString(); }) as GridValueFormatter, }, - { field: "technician", headerName: "Technician", flex: 1 }, + { + field: "technician", + headerName: "Technician", + flex: 1, + renderCell: (params) => { + const technicianName = String(params.value ?? ""); + const user = techniciansByName.get(technicianName); + + return ( + + + + {technicianName} + + + ); + }, + }, { field: "number_of_repairs", headerName: "Number of Repairs", type: "number", flex: 1, + align: "left", + headerAlign: "left", }, { field: "number_of_pms", headerName: "Number of Preventative Maintenances", type: "number", flex: 1, + align: "left", + headerAlign: "left", }, { field: "meter", @@ -467,6 +515,25 @@ export const MaintenanceReportView = () => { } }} groupBy={(option: User) => getRoleLabel(option)} + renderOption={(props: React.HTMLAttributes, option: User) => ( + + + + {option.full_name} + + + )} renderInput={(params: Parameters[0]) => { if (techiciansQuery.isLoading && params.inputProps) { params.inputProps.value = "Loading..."; @@ -486,6 +553,14 @@ export const MaintenanceReportView = () => { + } {...getTagProps({ index })} /> )) diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index 5cc0b7ec..caaff1c8 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -228,6 +228,19 @@ export const PartsUsedReportView = () => { return partsQuery.data; }, [partsQuery.data, partTypes]); + const groupedFilteredParts = useMemo(() => { + return [...filteredParts].sort((a, b) => { + const typeCompare = (a.part_type?.name ?? "").localeCompare( + b.part_type?.name ?? "", + ); + if (typeCompare !== 0) return typeCompare; + + return `${a.part_number} ${a.description}`.localeCompare( + `${b.part_number} ${b.description}`, + ); + }); + }, [filteredParts]); + useEffect(() => { if (!hydratedRef.current) return; @@ -492,7 +505,8 @@ export const PartsUsedReportView = () => { option.part_type?.name ?? "Other"} getOptionLabel={(option: Part) => `${option.part_number} ${option.description}` } From 33a3809a9886963dd3693f4f92bf66e1f4359198 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 16:07:32 -0500 Subject: [PATCH 86/91] fix(UserSelect): Patch the broken label state logic --- .../RHControlled/ControlledUserSelect.tsx | 96 +++++++++++++------ .../src/views/WorkOrders/WorkOrdersTable.tsx | 29 +++--- 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledUserSelect.tsx b/frontend/src/components/RHControlled/ControlledUserSelect.tsx index f377d4ec..a9b293e6 100644 --- a/frontend/src/components/RHControlled/ControlledUserSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledUserSelect.tsx @@ -1,7 +1,13 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useAuthUser } from "react-auth-kit"; import { Autocomplete, Box, Stack, TextField } from "@mui/material"; -import { Controller } from "react-hook-form"; +import { + Control, + Controller, + FieldValues, + Path, + UseFormSetValue, +} from "react-hook-form"; import { User } from "@/interfaces"; import { useGetUserList } from "@/service"; import { UserAvatar } from "@/components/UserAvatar"; @@ -13,21 +19,56 @@ import { const getAvatarRole = (user: User | null | undefined) => user ? getRoleLabel(user) : undefined; +const isUserLike = (value: unknown): value is User => { + if (typeof value !== "object" || value === null || !("id" in value)) { + return false; + } + + return typeof value.id === "number" && Number.isFinite(value.id); +}; + +type ControlledUserSelectProps = { + name: Path; + control: Control; + hideAndSelectCurrentUser?: boolean; + setValue?: UseFormSetValue | null; + label?: string; + error?: string; + helperText?: string; + disabled?: boolean; + sx?: object; +}; + export const ControlledUserSelect = ({ name, control, hideAndSelectCurrentUser = false, setValue = null, ...childProps -}: any) => { +}: ControlledUserSelectProps) => { const [isCurrentUserSet, setIsCurrentUserSet] = useState(false); + const currentUser = useAuthUser(); + const userList = useGetUserList(); + const users = useMemo( + () => sortUsersByRoleThenName(userList.data ?? []), + [userList.data], + ); + + useEffect(() => { + if (!hideAndSelectCurrentUser || isCurrentUserSet || !setValue) { + return; + } + + const authenticatedUser = currentUser(); + if (!authenticatedUser) { + return; + } + + setValue(name, authenticatedUser); + setIsCurrentUserSet(true); + }, [currentUser, hideAndSelectCurrentUser, isCurrentUserSet, name, setValue]); if (!hideAndSelectCurrentUser) { - const userList = useGetUserList(); - const users = useMemo( - () => sortUsersByRoleThenName(userList.data ?? []), - [userList.data], - ); const { label = "User", error, @@ -44,9 +85,10 @@ export const ControlledUserSelect = ({ defaultValue={null} render={({ field }) => ( (() => { + const fieldValue = isUserLike(field.value) ? field.value : null; const selectedUser = - users.find((user) => user.id === field.value?.id) ?? - field.value ?? + users.find((user) => user.id === fieldValue?.id) ?? + fieldValue ?? null; return ( @@ -80,6 +122,18 @@ export const ControlledUserSelect = ({ )} renderInput={(params) => { const { InputProps, ...rest } = params; + const startAdornment = selectedUser ? ( + <> + + {InputProps.startAdornment} + + ) : InputProps.startAdornment; return ( - {selectedUser ? ( - - ) : null} - {InputProps.startAdornment} - - ), + ...(startAdornment + ? { startAdornment } + : {}), }} /> ); @@ -114,11 +157,6 @@ export const ControlledUserSelect = ({ /> ); } else { - if (!isCurrentUserSet) { - const currentUser = useAuthUser(); - setValue(name, currentUser()); - setIsCurrentUserSet(true); - } return null; } }; diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index e79dd4b3..468eeb5b 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -492,26 +492,27 @@ export const WorkOrdersTable = () => { )} renderInput={(params) => { const { InputProps, ...rest } = params; + const startAdornment = selectedAssignedUser ? ( + <> + + {InputProps.startAdornment} + + ) : InputProps.startAdornment; return ( - {selectedAssignedUser ? ( - - ) : null} - {InputProps.startAdornment} - - ), + ...(startAdornment + ? { startAdornment } + : {}), }} label={ hasAdminScope From 1813acda13250479ffd818f39604174b6272561d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 13 Mar 2026 16:16:32 -0500 Subject: [PATCH 87/91] fix(ControlledUserSelect): Patch type errors --- .../RHControlled/ControlledUserSelect.tsx | 22 +++++++++++++------ .../MeterActivitySelection.tsx | 1 - .../SelectedObservationDetails.tsx | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledUserSelect.tsx b/frontend/src/components/RHControlled/ControlledUserSelect.tsx index a9b293e6..6dd37739 100644 --- a/frontend/src/components/RHControlled/ControlledUserSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledUserSelect.tsx @@ -6,6 +6,8 @@ import { Controller, FieldValues, Path, + PathValue, + UseControllerProps, UseFormSetValue, } from "react-hook-form"; import { User } from "@/interfaces"; @@ -27,6 +29,9 @@ const isUserLike = (value: unknown): value is User => { return typeof value.id === "number" && Number.isFinite(value.id); }; +const getUserId = (value: unknown): number | undefined => + isUserLike(value) ? value.id : undefined; + type ControlledUserSelectProps = { name: Path; control: Control; @@ -39,13 +44,13 @@ type ControlledUserSelectProps = { sx?: object; }; -export const ControlledUserSelect = ({ +export const ControlledUserSelect = ({ name, control, hideAndSelectCurrentUser = false, setValue = null, ...childProps -}: ControlledUserSelectProps) => { +}: ControlledUserSelectProps) => { const [isCurrentUserSet, setIsCurrentUserSet] = useState(false); const currentUser = useAuthUser(); const userList = useGetUserList(); @@ -64,7 +69,7 @@ export const ControlledUserSelect = ({ return; } - setValue(name, authenticatedUser); + setValue(name, authenticatedUser as PathValue>); setIsCurrentUserSet(true); }, [currentUser, hideAndSelectCurrentUser, isCurrentUserSet, name, setValue]); @@ -82,12 +87,15 @@ export const ControlledUserSelect = ({ ["defaultValue"]} render={({ field }) => ( (() => { - const fieldValue = isUserLike(field.value) ? field.value : null; - const selectedUser = - users.find((user) => user.id === fieldValue?.id) ?? + const fieldValueId = getUserId(field.value); + const fieldValue = isUserLike(field.value) + ? (field.value as User) + : null; + const selectedUser: User | null = + users.find((user) => user.id === fieldValueId) ?? fieldValue ?? null; diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx index 988d1a7c..f14690a1 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx @@ -33,7 +33,6 @@ export function MeterActivitySelection({ control, errors, setValue }: any) {
    From fbfc3b27395553c89cf67717c73d83f78af3c7cd Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 17 Mar 2026 16:06:01 -0500 Subject: [PATCH 88/91] feat(MapFullscreenToggle): Add full screen toggle to map --- .../src/components/MapFullscreenToggle.tsx | 96 +++++++++++++++++++ frontend/src/components/index.ts | 1 + .../MeterSelection/MeterSelectionMap.tsx | 10 ++ .../src/views/Reports/Chlorides/index.tsx | 11 ++- .../views/WellManagement/WellSelectionMap.tsx | 11 ++- 5 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/MapFullscreenToggle.tsx diff --git a/frontend/src/components/MapFullscreenToggle.tsx b/frontend/src/components/MapFullscreenToggle.tsx new file mode 100644 index 00000000..702bbdad --- /dev/null +++ b/frontend/src/components/MapFullscreenToggle.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState, type RefObject } from "react"; +import { Fullscreen, FullscreenExit } from "@mui/icons-material"; +import { IconButton, Tooltip } from "@mui/material"; +import { useMap } from "react-leaflet"; + +type MapFullscreenToggleProps = { + containerRef: RefObject; +}; + +const getFullscreenElement = () => + document.fullscreenElement as HTMLElement | null; + +const MapFullscreenSync = () => { + const map = useMap(); + + useEffect(() => { + const syncMapSize = () => { + window.setTimeout(() => { + map.invalidateSize(); + }, 0); + }; + + document.addEventListener("fullscreenchange", syncMapSize); + window.addEventListener("resize", syncMapSize); + + return () => { + document.removeEventListener("fullscreenchange", syncMapSize); + window.removeEventListener("resize", syncMapSize); + }; + }, [map]); + + return null; +}; + +export const MapFullscreenToggle = ({ + containerRef, +}: MapFullscreenToggleProps) => { + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const syncFullscreenState = () => { + setIsFullscreen(getFullscreenElement() === containerRef.current); + }; + + syncFullscreenState(); + document.addEventListener("fullscreenchange", syncFullscreenState); + + return () => { + document.removeEventListener("fullscreenchange", syncFullscreenState); + }; + }, [containerRef]); + + const handleToggleFullscreen = async () => { + const container = containerRef.current; + + if (!container) return; + + if (getFullscreenElement() === container) { + await document.exitFullscreen(); + return; + } + + await container.requestFullscreen(); + }; + + return ( + <> + + + { + void handleToggleFullscreen(); + }} + sx={{ + position: "absolute", + top: 10, + right: 10, + zIndex: 1000, + backgroundColor: "rgba(255, 255, 255, 0.95)", + border: "1px solid rgba(0, 0, 0, 0.2)", + boxShadow: "0 1px 5px rgba(0, 0, 0, 0.35)", + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 1)", + }, + }} + > + {isFullscreen ? : } + + + + ); +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 42b9dab1..bfbfdbae 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -13,6 +13,7 @@ export * from "./IsTrueChip"; export * from "./Layers"; export * from "./LinkBehavior"; export * from "./ManageBreadcrumbTitle"; +export * from "./MapFullscreenToggle"; export * from "./MapUrlStateSync"; export * from "./MergeWellModal"; export * from "./MeterMapColorLegend"; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 45c5e1a6..1522a538 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { useDebounce } from "use-debounce"; import { MapContainer, @@ -29,6 +30,7 @@ import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import { OpenStreetMapLayer, SatelliteLayer, + MapFullscreenToggle, MeterMapColorLegend, TransporationLayer, BoundariesLayer, @@ -74,6 +76,7 @@ export default function MeterSelectionMap({ const navigate = useNavigate(); const [meterSearchDebounced] = useDebounce(meterSearch, 250); const meterMarkers = useGetMeterLocations(meterSearchDebounced); + const mapContainerRef = useRef(null); const mapBaseLayer = normalizeMapBaseLayer( search.mapBase, BASE_LAYER_NAMES, @@ -99,11 +102,17 @@ export default function MeterSelectionMap({ return ( <> @@ -237,6 +246,7 @@ export default function MeterSelectionMap({ checked={mapOverlayNames.includes("Boundaries and Places")} /> + diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 21c3cb45..6f81e691 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { PictureAsPdf, ScienceOutlined } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; @@ -34,6 +34,7 @@ import { CustomCardHeader, BackgroundBox, DirectionCard, + MapFullscreenToggle, MapUrlStateSync, ReportBreadcrumbTitle, SoutheastGuideLayer, @@ -110,6 +111,7 @@ const DEFAULT_OVERLAYS = ["Clorides Report Region Guide", "Wells"]; export const ChloridesReportView = () => { const navigate = useNavigate(); const search = Route.useSearch(); + const mapContainerRef = useRef(null); const mapBaseLayer = normalizeMapBaseLayer( search.mapBase, BASE_LAYER_NAMES, @@ -393,11 +395,17 @@ export const ChloridesReportView = () => { @@ -489,6 +497,7 @@ export const ChloridesReportView = () => { )} /> + diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index d56ee7f2..541f6afe 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useDebounce } from "use-debounce"; import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; import { Box, Typography } from "@mui/material"; @@ -12,6 +12,7 @@ import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, + MapFullscreenToggle, TransporationLayer, WellMapLegend, } from "@/components"; @@ -51,6 +52,7 @@ export default function WellSelectionMap({ }) { const navigate = useNavigate(); const search = Route.useSearch(); + const mapContainerRef = useRef(null); const [wellSearchDebounced] = useDebounce(wellSearchQueryProp, 250); const wellQuery = useGetWellLocations(wellSearchDebounced); @@ -101,11 +103,17 @@ export default function WellSelectionMap({ return ( <> @@ -190,6 +198,7 @@ export default function WellSelectionMap({ checked={mapOverlayNames.includes("Boundaries and Places")} /> + From 72e2c9a7c329f57783cce5f820d40bca7764711a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 17 Mar 2026 22:59:11 -0500 Subject: [PATCH 89/91] feat(Notification): Create table & apis --- api/main.py | 3 + api/models/main_models.py | 42 ++ api/routes/notifications.py | 190 +++++++ api/schemas/notification_schemas.py | 42 ++ .../Modals/Notifications/Create.tsx | 217 ++++++++ .../components/Modals/Notifications/index.ts | 1 + frontend/src/components/Modals/index.ts | 1 + .../RHControlled/ControlledUserSelect.tsx | 60 +- frontend/src/components/Topbar.tsx | 43 +- .../interfaces/CreateNotificationPayload.ts | 8 + frontend/src/interfaces/Notification.ts | 14 + .../interfaces/NotificationCreateResult.ts | 3 + .../src/interfaces/NotificationQueryParams.ts | 9 + frontend/src/interfaces/NotificationType.ts | 5 + frontend/src/interfaces/index.ts | 5 + frontend/src/routes/notifications.tsx | 24 + frontend/src/service/ApiServiceNew.ts | 134 ++++- frontend/src/views/Notifications.tsx | 513 +++++++++++++++--- ...221411_create_notifications_table.down.sql | 8 + ...17221411_create_notifications_table.up.sql | 54 ++ 20 files changed, 1255 insertions(+), 121 deletions(-) create mode 100644 api/routes/notifications.py create mode 100644 api/schemas/notification_schemas.py create mode 100644 frontend/src/components/Modals/Notifications/Create.tsx create mode 100644 frontend/src/components/Modals/Notifications/index.ts create mode 100644 frontend/src/interfaces/CreateNotificationPayload.ts create mode 100644 frontend/src/interfaces/Notification.ts create mode 100644 frontend/src/interfaces/NotificationCreateResult.ts create mode 100644 frontend/src/interfaces/NotificationQueryParams.ts create mode 100644 frontend/src/interfaces/NotificationType.ts create mode 100644 migrations/20260317221411_create_notifications_table.down.sql create mode 100644 migrations/20260317221411_create_notifications_table.up.sql diff --git a/api/main.py b/api/main.py index c6dc898e..7c6edd7c 100644 --- a/api/main.py +++ b/api/main.py @@ -14,6 +14,7 @@ public_maintenance_router, ) from api.routes.meters import authenticated_meter_router, public_meter_router +from api.routes.notifications import notifications_router from api.routes.OSE import ose_router from api.routes.parts import part_router from api.routes.settings import settings_router @@ -43,6 +44,7 @@ "name": "WaterLevels", "description": "Groundwater Depth and Chloride Measurement Related Endpoints", }, + {"name": "Notifications", "description": "Notification related endpoints"}, {"name": "OSE", "description": "Endpoints Used by the OSE to Generate Reports"}, {"name": "Admin", "description": "Admin Functionality Related Endpoints"}, {"name": "Login", "description": "User Auth and Token Related Endpoints"}, @@ -121,6 +123,7 @@ def login_for_access_token( authenticated_router.include_router(authenticated_chlorides_router) authenticated_router.include_router(authenticated_maintenance_router) authenticated_router.include_router(authenticated_meter_router) +authenticated_router.include_router(notifications_router) authenticated_router.include_router(part_router) authenticated_router.include_router(authenticated_well_measurement_router) authenticated_router.include_router(authenticated_well_router) diff --git a/api/models/main_models.py b/api/models/main_models.py index 693e2893..34c604b1 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -472,6 +472,48 @@ class Users(Base): display_name: Mapped[str] = mapped_column(String, nullable=True) redirect_page: Mapped[str] = mapped_column(String, nullable=True, default="/") avatar_img: Mapped[str] = mapped_column(String, nullable=True) + notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", back_populates="user", cascade="all, delete-orphan" + ) + + +class NotificationTypeLU(Base): + __tablename__ = "notification_type_lu" + + name: Mapped[str] = mapped_column(String(50), nullable=False, unique=True) + description: Mapped[Optional[str]] = mapped_column(String) + + notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", back_populates="notification_type" + ) + + +class Notifications(Base): + __tablename__ = "notifications" + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="CASCADE", onupdate="CASCADE"), index=True + ) + notification_type_id: Mapped[int] = mapped_column( + Integer, + ForeignKey( + "notification_type_lu.id", ondelete="RESTRICT", onupdate="CASCADE" + ), + index=True, + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + message: Mapped[str] = mapped_column(String, nullable=False) + link: Mapped[Optional[str]] = mapped_column(String(500)) + is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + created_at: Mapped[DateTime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), index=True + ) + read_at: Mapped[Optional[DateTime]] = mapped_column(DateTime) + + user: Mapped["Users"] = relationship("Users", back_populates="notifications") + notification_type: Mapped["NotificationTypeLU"] = relationship( + "NotificationTypeLU", back_populates="notifications" + ) # Association table that links roles and their associated scopes diff --git a/api/routes/notifications.py b/api/routes/notifications.py new file mode 100644 index 00000000..8de59af5 --- /dev/null +++ b/api/routes/notifications.py @@ -0,0 +1,190 @@ +from datetime import date, datetime, time + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_pagination import LimitOffsetPage +from fastapi_pagination.ext.sqlalchemy import paginate +from sqlalchemy import func, select +from sqlalchemy.orm import Session, joinedload + +from api.enums import ScopedUser +from api.models.main_models import Notifications, NotificationTypeLU, Users +from api.schemas.notification_schemas import ( + NotificationCreateRequest, + NotificationCreateResult, + Notification, + NotificationReadUpdate, + NotificationType, + NotificationUnreadCount, +) +from api.security import get_current_user +from api.session import get_db + +notifications_router = APIRouter() + + +@notifications_router.get( + "/notifications", + dependencies=[Depends(ScopedUser.Read)], + response_model=LimitOffsetPage[Notification], + tags=["Notifications"], +) +def get_notifications( + q: str | None = None, + is_read: bool | None = None, + notification_type_id: list[int] | None = Query(None), + created_from: date | None = None, + created_to: date | None = None, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + query_statement = ( + select(Notifications) + .options(joinedload(Notifications.notification_type)) + .where(Notifications.user_id == user.id) + .order_by(Notifications.created_at.desc(), Notifications.id.desc()) + ) + + if q: + ilike_term = f"%{q.strip()}%" + query_statement = query_statement.where( + Notifications.title.ilike(ilike_term) + | Notifications.message.ilike(ilike_term) + | Notifications.link.ilike(ilike_term) + ) + + if is_read is not None: + query_statement = query_statement.where(Notifications.is_read == is_read) + + if notification_type_id: + query_statement = query_statement.where( + Notifications.notification_type_id.in_(notification_type_id) + ) + + if created_from is not None: + query_statement = query_statement.where( + Notifications.created_at >= datetime.combine(created_from, time.min) + ) + + if created_to is not None: + query_statement = query_statement.where( + Notifications.created_at <= datetime.combine(created_to, time.max) + ) + + return paginate(db, query_statement) + + +@notifications_router.get( + "/notification_types", + dependencies=[Depends(ScopedUser.Read)], + response_model=list[NotificationType], + tags=["Notifications"], +) +def get_notification_types(db: Session = Depends(get_db)): + return db.scalars( + select(NotificationTypeLU).order_by(func.lower(NotificationTypeLU.name)) + ).all() + + +@notifications_router.get( + "/notifications/unread_count", + dependencies=[Depends(ScopedUser.Read)], + response_model=NotificationUnreadCount, + tags=["Notifications"], +) +def get_unread_notification_count( + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + unread_count = db.scalar( + select(func.count(Notifications.id)).where( + Notifications.user_id == user.id, Notifications.is_read.is_(False) + ) + ) + + return {"unread_count": unread_count or 0} + + +@notifications_router.post( + "/notifications", + dependencies=[Depends(ScopedUser.Admin)], + response_model=NotificationCreateResult, + tags=["Notifications"], +) +def create_notifications( + payload: NotificationCreateRequest, + db: Session = Depends(get_db), +): + user_ids = set(payload.user_ids) + + if payload.role_ids: + role_user_ids = db.scalars( + select(Users.id).where( + Users.user_role_id.in_(payload.role_ids), Users.disabled.is_(False) + ) + ).all() + user_ids.update(role_user_ids) + + if user_ids: + valid_user_ids = db.scalars( + select(Users.id).where(Users.id.in_(user_ids), Users.disabled.is_(False)) + ).all() + user_ids = set(valid_user_ids) + + if not user_ids: + raise HTTPException( + status_code=400, + detail="At least one active user or role recipient is required", + ) + + notification_type_exists = db.scalar( + select(NotificationTypeLU.id).where( + NotificationTypeLU.id == payload.notification_type_id + ) + ) + if not notification_type_exists: + raise HTTPException(status_code=404, detail="Notification type not found") + + notifications = [ + Notifications( + user_id=user_id, + notification_type_id=payload.notification_type_id, + title=payload.title.strip(), + message=payload.message.strip(), + link=payload.link.strip() if payload.link else None, + ) + for user_id in user_ids + ] + + db.add_all(notifications) + db.commit() + + return {"created_count": len(notifications)} + + +@notifications_router.patch( + "/notifications", + dependencies=[Depends(ScopedUser.Read)], + response_model=Notification, + tags=["Notifications"], +) +def update_notification_read_status( + payload: NotificationReadUpdate, + db: Session = Depends(get_db), + user: Users = Depends(get_current_user), +): + notification = db.scalar( + select(Notifications) + .options(joinedload(Notifications.notification_type)) + .where(Notifications.id == payload.id, Notifications.user_id == user.id) + ) + + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + notification.is_read = payload.is_read + notification.read_at = datetime.now() if payload.is_read else None + + db.commit() + db.refresh(notification) + + return notification diff --git a/api/schemas/notification_schemas.py b/api/schemas/notification_schemas.py new file mode 100644 index 00000000..41817662 --- /dev/null +++ b/api/schemas/notification_schemas.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from api.schemas.base import ORMBase + + +class NotificationType(ORMBase): + name: str + description: str | None = None + + +class Notification(ORMBase): + user_id: int + notification_type_id: int + title: str + message: str + link: str | None = None + is_read: bool + created_at: datetime + read_at: datetime | None = None + notification_type: NotificationType + + +class NotificationUnreadCount(ORMBase): + unread_count: int + + +class NotificationCreateRequest(ORMBase): + role_ids: list[int] = [] + user_ids: list[int] = [] + notification_type_id: int + title: str + message: str + link: str | None = None + + +class NotificationCreateResult(ORMBase): + created_count: int + + +class NotificationReadUpdate(ORMBase): + id: int + is_read: bool diff --git a/frontend/src/components/Modals/Notifications/Create.tsx b/frontend/src/components/Modals/Notifications/Create.tsx new file mode 100644 index 00000000..8862096d --- /dev/null +++ b/frontend/src/components/Modals/Notifications/Create.tsx @@ -0,0 +1,217 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Autocomplete, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + TextField, +} from "@mui/material"; +import { Save } from "@mui/icons-material"; +import { useForm } from "react-hook-form"; +import { ControlledUserSelect } from "@/components"; +import { + CreateNotificationPayload, + NotificationType, + User, + UserRole, +} from "@/interfaces"; +import { getRoleColor } from "@/utils"; + +const getRoleChipColor = (role?: string) => { + const color = getRoleColor(role); + return color === "inherit" ? "default" : color; +}; + +const formatNotificationTypeName = (value: string) => + value + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + +type FormValues = { + users: User[]; +}; + +export const CreateNotificationModal = ({ + open, + onClose, + users, + roles, + notificationTypes, + onSubmit, + loading, +}: { + open: boolean; + onClose: () => void; + users: User[]; + roles: UserRole[]; + notificationTypes: NotificationType[]; + onSubmit: (payload: CreateNotificationPayload) => void; + loading?: boolean; +}) => { + const activeUsers = useMemo( + () => users.filter((user) => !user.disabled), + [users], + ); + const { control, reset, watch } = useForm({ + defaultValues: { users: [] }, + }); + const selectedUsers = watch("users") ?? []; + + const [selectedRoles, setSelectedRoles] = useState([]); + const [selectedType, setSelectedType] = useState(null); + const [title, setTitle] = useState(""); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!open) return; + reset({ users: [] }); + setSelectedRoles([]); + setSelectedType(notificationTypes[0] ?? null); + setTitle(""); + setMessage(""); + }, [open, notificationTypes, reset]); + + const hasRecipients = selectedUsers.length > 0 || selectedRoles.length > 0; + const canSave = + hasRecipients && + !!selectedType && + title.trim().length > 0 && + message.trim().length > 0; + + const handleSubmit = () => { + if (!canSave || !selectedType) return; + + onSubmit({ + user_ids: selectedUsers.map((user) => user.id), + role_ids: selectedRoles.map((role) => role.id), + notification_type_id: selectedType.id, + title: title.trim(), + message: message.trim(), + }); + }; + + return ( + + + Create Notification + + + + + Select one or more roles or individual users, then enter the + notification details. + + setSelectedRoles(value)} + getOptionLabel={(option) => option.name} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderTags={(selected, getTagProps) => + selected.map((option, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + setSelectedType(value)} + getOptionLabel={(option) => formatNotificationTypeName(option.name)} + isOptionEqualToValue={(a, b) => a.id === b.id} + renderInput={(params) => ( + + )} + /> + setTitle(event.target.value)} + error={title.trim().length === 0} + helperText={title.trim().length === 0 ? "Title is required." : " "} + /> + setMessage(event.target.value)} + multiline + minRows={3} + error={message.trim().length === 0} + helperText={message.trim().length === 0 ? "Message is required." : " "} + /> + + + + + + + + ); +}; diff --git a/frontend/src/components/Modals/Notifications/index.ts b/frontend/src/components/Modals/Notifications/index.ts new file mode 100644 index 00000000..c65721e2 --- /dev/null +++ b/frontend/src/components/Modals/Notifications/index.ts @@ -0,0 +1 @@ +export * from "./Create"; diff --git a/frontend/src/components/Modals/index.ts b/frontend/src/components/Modals/index.ts index b465421f..222f727c 100644 --- a/frontend/src/components/Modals/index.ts +++ b/frontend/src/components/Modals/index.ts @@ -1,2 +1,3 @@ +export * from "./Notifications"; export * from "./Region"; export * from "./Parts"; diff --git a/frontend/src/components/RHControlled/ControlledUserSelect.tsx b/frontend/src/components/RHControlled/ControlledUserSelect.tsx index 6dd37739..626d78bb 100644 --- a/frontend/src/components/RHControlled/ControlledUserSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledUserSelect.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { Autocomplete, Box, Stack, TextField } from "@mui/material"; +import { Autocomplete, Box, Chip, Stack, TextField } from "@mui/material"; import { Control, Controller, @@ -37,11 +37,13 @@ type ControlledUserSelectProps = { control: Control; hideAndSelectCurrentUser?: boolean; setValue?: UseFormSetValue | null; + options?: User[]; label?: string; error?: string; helperText?: string; disabled?: boolean; sx?: object; + multiple?: boolean; }; export const ControlledUserSelect = ({ @@ -54,9 +56,12 @@ export const ControlledUserSelect = ({ const [isCurrentUserSet, setIsCurrentUserSet] = useState(false); const currentUser = useAuthUser(); const userList = useGetUserList(); + const providedOptions = Array.isArray(childProps.options) + ? childProps.options + : undefined; const users = useMemo( - () => sortUsersByRoleThenName(userList.data ?? []), - [userList.data], + () => sortUsersByRoleThenName(providedOptions ?? userList.data ?? []), + [providedOptions, userList.data], ); useEffect(() => { @@ -80,6 +85,7 @@ export const ControlledUserSelect = ({ helperText, disabled, sx, + multiple = false, ...autocompleteProps } = childProps; @@ -90,33 +96,47 @@ export const ControlledUserSelect = ({ defaultValue={null as UseControllerProps["defaultValue"]} render={({ field }) => ( (() => { + const selectedUsers: User[] = multiple + ? Array.isArray(field.value) + ? field.value + .map((value: unknown) => { + const valueId = getUserId(value); + return users.find((user) => user.id === valueId); + }) + .filter(Boolean) + : [] + : []; const fieldValueId = getUserId(field.value); const fieldValue = isUserLike(field.value) ? (field.value as User) : null; const selectedUser: User | null = - users.find((user) => user.id === fieldValueId) ?? - fieldValue ?? - null; + !multiple + ? users.find((user) => user.id === fieldValueId) ?? + fieldValue ?? + null + : null; return ( - + {...autocompleteProps} - {...field} size="small" + multiple={multiple} options={users} groupBy={(user: User) => getRoleLabel(user)} getOptionLabel={(user: User) => user?.full_name ?? ""} isOptionEqualToValue={(option: User, value: User) => option.id === value.id } - value={selectedUser} + value={multiple ? selectedUsers : selectedUser} onChange={(_, newValue) => field.onChange(newValue)} loading={userList.isLoading} - disabled={disabled ?? userList.isLoading} + disabled={ + disabled ?? (providedOptions ? false : userList.isLoading) + } sx={sx} renderOption={(props, option) => ( - + ({ )} + renderTags={(selected: readonly User[], getTagProps) => + selected.map((option, index) => ( + + } + {...getTagProps({ index })} + /> + )) + } renderInput={(params) => { const { InputProps, ...rest } = params; - const startAdornment = selectedUser ? ( + const startAdornment = !multiple && selectedUser ? ( <> ( null, @@ -62,11 +65,16 @@ export const Topbar = ({ const [publicMenuAnchorEl, setPublicMenuAnchorEl] = useState(null); - const role: string = authUser()?.user_role?.name; - const fullName = - authUser()?.full_name ?? authUser()?.display_name ?? "Unknown"; - const email = authUser()?.email ?? "No email available"; - const isLoggedIn = !!authUser(); + const user = authUser(); + const role: string = user?.user_role?.name; + const fullName = user?.full_name ?? user?.display_name ?? "Unknown"; + const email = user?.email ?? "No email available"; + const isLoggedIn = !!user; + const unreadNotificationsQuery = useGetUnreadNotificationCount({ + enabled: isLoggedIn, + }); + const unreadNotificationCount = + unreadNotificationsQuery.data?.unread_count ?? 0; const isPublicDataActive = isChloridesActive || isMonitoringWellsActive; const effectiveSidebarWidth = isDesktop && isLoggedIn @@ -279,34 +287,40 @@ export const Topbar = ({ {isLoggedIn ? ( navigate({ to: "/notifications", search: {} })} sx={{ width: { xs: 35, md: 40, lg: 44 }, height: { xs: 35, md: 40, lg: 44 }, color: isNotificationsActive ? "darkblue" : "text.secondary", - border: "1px solid", + border: isNotificationsActive ? "1px solid" : undefined, borderColor: isNotificationsActive ? "rgba(0, 0, 139, 0.24)" - : "divider", + : undefined, bgcolor: isNotificationsActive ? "rgba(0, 0, 139, 0.08)" - : "rgba(255, 255, 255, 0.76)", + : undefined, "&:hover": { bgcolor: isNotificationsActive ? "rgba(0, 0, 139, 0.14)" - : "rgba(15, 23, 42, 0.04)", + : undefined, }, }} > - + + +
    @@ -381,6 +395,7 @@ export const Topbar = ({ { navigate({ to: "/settings", search: {} }); handleMenuClose(); @@ -395,7 +410,7 @@ export const Topbar = ({ { navigate({ to: "/notifications", search: {} }); handleMenuClose(); diff --git a/frontend/src/interfaces/CreateNotificationPayload.ts b/frontend/src/interfaces/CreateNotificationPayload.ts new file mode 100644 index 00000000..82c67134 --- /dev/null +++ b/frontend/src/interfaces/CreateNotificationPayload.ts @@ -0,0 +1,8 @@ +export interface CreateNotificationPayload { + role_ids: number[]; + user_ids: number[]; + notification_type_id: number; + title: string; + message: string; + link?: string; +} diff --git a/frontend/src/interfaces/Notification.ts b/frontend/src/interfaces/Notification.ts new file mode 100644 index 00000000..ead62883 --- /dev/null +++ b/frontend/src/interfaces/Notification.ts @@ -0,0 +1,14 @@ +import { NotificationType } from "./NotificationType"; + +export interface Notification { + id: number; + user_id: number; + notification_type_id: number; + title: string; + message: string; + link?: string | null; + is_read: boolean; + created_at: string; + read_at?: string | null; + notification_type: NotificationType; +} diff --git a/frontend/src/interfaces/NotificationCreateResult.ts b/frontend/src/interfaces/NotificationCreateResult.ts new file mode 100644 index 00000000..3bdd4fc2 --- /dev/null +++ b/frontend/src/interfaces/NotificationCreateResult.ts @@ -0,0 +1,3 @@ +export interface NotificationCreateResult { + created_count: number; +} diff --git a/frontend/src/interfaces/NotificationQueryParams.ts b/frontend/src/interfaces/NotificationQueryParams.ts new file mode 100644 index 00000000..d9176f2d --- /dev/null +++ b/frontend/src/interfaces/NotificationQueryParams.ts @@ -0,0 +1,9 @@ +export interface NotificationQueryParams { + q?: string; + is_read?: boolean; + notification_type_id?: number[]; + created_from?: string; + created_to?: string; + limit?: number; + offset?: number; +} diff --git a/frontend/src/interfaces/NotificationType.ts b/frontend/src/interfaces/NotificationType.ts new file mode 100644 index 00000000..ef2493d0 --- /dev/null +++ b/frontend/src/interfaces/NotificationType.ts @@ -0,0 +1,5 @@ +export interface NotificationType { + id: number; + name: string; + description?: string | null; +} diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index eb72bd4b..b671acd0 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -4,6 +4,7 @@ export * from "./ActivityTypeLU"; export * from "./BackupRow"; export * from "./BaseWell"; export * from "./CreateUser"; +export * from "./CreateNotificationPayload"; export * from "./DeviceAttributes"; export * from "./DevicePayload"; export * from "./HomeSummary"; @@ -32,6 +33,10 @@ export * from "./NewRegionMeasurement"; export * from "./NewUser"; export * from "./NewWellMeasurement"; export * from "./NewWorkOrder"; +export * from "./Notification"; +export * from "./NotificationCreateResult"; +export * from "./NotificationQueryParams"; +export * from "./NotificationType"; export * from "./NoteTypeLU"; export * from "./ObservationForm"; export * from "./ObservedPropertyTypeLU"; diff --git a/frontend/src/routes/notifications.tsx b/frontend/src/routes/notifications.tsx index e7783046..04d63af5 100644 --- a/frontend/src/routes/notifications.tsx +++ b/frontend/src/routes/notifications.tsx @@ -1,8 +1,32 @@ import { createFileRoute } from "@tanstack/react-router"; +import dayjs from "dayjs"; +import { z } from "zod"; import { Notifications } from "@/views"; import { ProtectedRoute } from "@/ProtectedRoute"; +import { + isoDateParam, + pageParam, + positiveIntListParam, + routeSearchHydrator, + triStateParam, +} from "@/utils"; + +const searchSchema = z.object({ + q: z.string().catch("").default(""), + is_read: triStateParam("false"), + notification_type_id: positiveIntListParam, + created_from: isoDateParam.catch(undefined).default(undefined), + created_to: isoDateParam + .catch(dayjs().endOf("month").format("YYYY-MM-DD")) + .default(dayjs().endOf("month").format("YYYY-MM-DD")), + page: pageParam(0, 0), + pageSize: pageParam(25, 10), +}); export const Route = createFileRoute("/notifications")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), component: () => ( diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index f3322cb6..fb8597cc 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -10,12 +10,17 @@ import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { ActivityTypeLU, + CreateNotificationPayload, HomeSummary, MeterListDTO, MeterListQueryParams, MeterTypeLU, NewWellMeasurement, NoteTypeLU, + Notification, + NotificationCreateResult, + NotificationQueryParams, + NotificationType, ObservedPropertyTypeLU, Page, ST2Measurement, @@ -372,6 +377,54 @@ export function useGetHomeSummary() { ); } +export function useGetNotifications( + params: NotificationQueryParams | undefined, + options?: UseQueryOptions, Error>, +) { + const route = "notifications"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery, Error>( + [route, params], + () => GETFetch(route, params, authHeader(), signOut, navigate), + { + keepPreviousData: true, + ...options, + }, + ); +} + +export function useGetNotificationTypes() { + const route = "notification_types"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery([route], () => + GETFetch(route, null, authHeader(), signOut, navigate), + ); +} + +export function useGetUnreadNotificationCount( + options?: UseQueryOptions<{ unread_count: number }, Error>, +) { + const route = "notifications/unread_count"; + const authHeader = useAuthHeader(); + const navigate = useNavigate(); + const signOut = useSignOut(); + + return useQuery<{ unread_count: number }, Error>( + [route], + () => GETFetch(route, null, authHeader(), signOut, navigate), + { + refetchInterval: 60_000, + ...options, + }, + ); +} + export function useGetMeterRegisterList() { const route = "meter_registers"; const authHeader = useAuthHeader(); @@ -429,25 +482,29 @@ export function useGetSecurityScopes() { ); } -export function useGetRoles() { +export function useGetRoles(options?: UseQueryOptions) { const route = "roles"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery([route], () => - GETFetch(route, null, authHeader(), signOut, navigate), + return useQuery( + [route], + () => GETFetch(route, null, authHeader(), signOut, navigate), + options, ); } -export function useGetUserAdminList() { +export function useGetUserAdminList(options?: UseQueryOptions) { const route = "usersadmin"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery([route], () => - GETFetch(route, null, authHeader(), signOut, navigate), + return useQuery( + [route], + () => GETFetch(route, null, authHeader(), signOut, navigate), + options, ); } @@ -900,6 +957,71 @@ export function useCreateRole(onSuccess: Function) { }); } +export function useCreateNotifications(onSuccess: Function) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const route = "notifications"; + const authHeader = useAuthHeader(); + + return useMutation({ + mutationFn: async (payload: CreateNotificationPayload) => { + const response = await POSTFetch(route, payload, authHeader()); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + const responseJson: NotificationCreateResult = await response.json(); + onSuccess(responseJson); + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + return responseJson; + }, + onSuccess: (result) => { + enqueueSnackbar( + `Created ${result.created_count} notification${result.created_count === 1 ? "" : "s"}.`, + { + variant: "success", + }, + ); + }, + retry: 0, + }); +} + +export function useUpdateNotificationReadStatus(onSuccess?: Function) { + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const route = "notifications"; + const authHeader = useAuthHeader(); + + return useMutation({ + mutationFn: async (payload: { id: number; is_read: boolean }) => { + const response = await PATCHFetch(route, payload, authHeader()); + + if (!response.ok) { + const errorMessage = + (await response.json().catch(() => null))?.detail ?? + `Error ${response.status}`; + enqueueSnackbar(errorMessage, { variant: "error" }); + throw Error(errorMessage); + } + + return response.json(); + }, + onSuccess: (result) => { + queryClient.invalidateQueries("notifications"); + queryClient.invalidateQueries("notifications/unread_count"); + onSuccess?.(result); + }, + retry: 0, + }); +} + export function useUpdateWell(onSuccess: Function) { const { enqueueSnackbar } = useSnackbar(); const route = "wells"; diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx index 5af4280e..e9212685 100644 --- a/frontend/src/views/Notifications.tsx +++ b/frontend/src/views/Notifications.tsx @@ -1,107 +1,442 @@ +import { useEffect, useMemo, useState } from "react"; +import dayjs from "dayjs"; import { + Alert, Box, + Button, Card, CardContent, - Divider, - List, - ListItem, - ListItemIcon, - ListItemText, - Stack, - Typography, + Checkbox, + Chip, + FormControl, + Grid, + InputAdornment, + InputLabel, + MenuItem, + Select, + TextField, } from "@mui/material"; -import NotificationsOutlinedIcon from "@mui/icons-material/NotificationsOutlined"; -import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined"; -import MarkEmailReadOutlinedIcon from "@mui/icons-material/MarkEmailReadOutlined"; -import { BackgroundBox, CustomCardHeader } from "@/components"; +import { Add, NotificationsOutlined, Search } from "@mui/icons-material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { DatePicker } from "@mui/x-date-pickers"; +import { useNavigate } from "@tanstack/react-router"; +import { useAuthUser } from "react-auth-kit"; +import { + BackgroundBox, + CreateNotificationModal, + CustomCardHeader, + TristateToggle, +} from "@/components"; +import { Notification, SecurityScope } from "@/interfaces"; +import { Route } from "@/routes/notifications"; +import { + useCreateNotifications, + useGetNotifications, + useGetNotificationTypes, + useGetRoles, + useGetUnreadNotificationCount, + useUpdateNotificationReadStatus, + useGetUserAdminList, +} from "@/service"; -const notificationItems = [ - { - title: "No new alerts right now", - description: - "System notifications will appear here when new activity needs your attention.", - icon: NotificationsOutlinedIcon, - }, - { - title: "Profile and account changes", - description: - "Updates related to your account preferences and settings will be listed here.", - icon: SettingsOutlinedIcon, - }, - { - title: "Read status support", - description: - "This page is ready for unread and read notification states when those are added.", - icon: MarkEmailReadOutlinedIcon, - }, -] as const; +const formatNotificationTypeName = (value: string) => + value + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); export const Notifications = () => { + const navigate = useNavigate(); + const authUser = useAuthUser(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const search = Route.useSearch(); + const isAdmin = + authUser()?.user_role?.security_scopes?.some( + (scope: SecurityScope) => scope.scope_string === "admin", + ) ?? false; + const notificationTypesQuery = useGetNotificationTypes(); + const rolesQuery = useGetRoles({ enabled: isAdmin }); + const usersQuery = useGetUserAdminList({ enabled: isAdmin }); + const createNotifications = useCreateNotifications(() => { + setIsCreateModalOpen(false); + }); + const updateNotificationReadStatus = useUpdateNotificationReadStatus(); + const notificationTypeIds = useMemo( + () => (notificationTypesQuery.data ?? []).map((type) => type.id), + [notificationTypesQuery.data], + ); + + useEffect(() => { + if (!notificationTypeIds.length || search.notification_type_id.length) + return; + + setSearch((prev) => ({ + ...prev, + notification_type_id: notificationTypeIds, + page: 0, + })); + }, [notificationTypeIds, search.notification_type_id.length]); + + const notificationsQuery = useGetNotifications({ + q: search.q || undefined, + is_read: + search.is_read === "all" + ? undefined + : search.is_read === "true" + ? true + : false, + notification_type_id: + search.notification_type_id.length > 0 + ? search.notification_type_id + : notificationTypeIds.length > 0 + ? notificationTypeIds + : undefined, + created_from: search.created_from, + created_to: search.created_to, + limit: search.pageSize, + offset: search.page * search.pageSize, + }); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/notifications", + search: (prev) => updater(prev as typeof search), + replace: true, + }); + }; + + const columns = useMemo[]>( + () => [ + { + field: "read_toggle", + headerName: "Mark Read", + minWidth: 110, + flex: 0.7, + sortable: false, + filterable: false, + renderCell: (params) => ( + + updateNotificationReadStatus.mutate({ + id: params.row.id, + is_read: checked, + }) + } + /> + ), + }, + { + field: "created_at", + headerName: "Created", + minWidth: 190, + flex: 1.1, + valueFormatter: (value) => + value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", + }, + { + field: "notification_type", + headerName: "Type", + minWidth: 140, + flex: 0.9, + sortable: false, + valueGetter: (_, row) => row.notification_type?.name ?? "", + renderCell: (params) => ( + + ), + }, + { + field: "is_read", + headerName: "Status", + minWidth: 110, + flex: 0.7, + renderCell: (params) => ( + + ), + }, + { + field: "title", + headerName: "Title", + minWidth: 220, + flex: 1.4, + }, + { + field: "message", + headerName: "Message", + minWidth: 320, + flex: 2.3, + }, + { + field: "link", + headerName: "Link", + minWidth: 180, + flex: 1.2, + sortable: false, + renderCell: (params) => { + const value = params.value as string | null | undefined; + if (!value) return "-"; + + return ( + + Open + + ); + }, + }, + { + field: "read_at", + headerName: "Read At", + minWidth: 190, + flex: 1.1, + valueFormatter: (value) => + value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", + }, + ], + [], + ); + return ( - - - - - + + + + + + setSearch((prev) => ({ + ...prev, + created_from: + value && value.isValid() + ? value.format("YYYY-MM-DD") + : undefined, + page: 0, + })) + } + views={["year", "month", "day"]} + openTo="year" + format="YYYY MMMM DD" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + + + + setSearch((prev) => ({ + ...prev, + created_to: + value && value.isValid() + ? value.format("YYYY-MM-DD") + : undefined, + page: 0, + })) + } + views={["year", "month", "day"]} + openTo="year" + format="YYYY MMMM DD" + slotProps={{ textField: { size: "small", fullWidth: true } }} + /> + + + + Type + + + + + + + setSearch((prev) => ({ + ...prev, + q: e.target.value, + page: 0, + })) + } + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + - - Notification Center - - - There are no live notifications connected yet. This page gives - the topbar menu and bell button a dedicated destination. - - + + setSearch((prev) => ({ + ...prev, + is_read: next, + page: 0, + })) + } + /> + + - - - {notificationItems.map((item, index) => { - const Icon = item.icon; + {notificationsQuery.error ? ( + + Failed to load notifications. + + ) : null} - return ( - - - - - - - {item.title} - - } - secondary={ - - {item.description} - - } - /> - - {index < notificationItems.length - 1 ? : null} - - ); - })} - - - + + + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } + disableRowSelectionOnClick + disableColumnMenu + getRowHeight={() => "auto"} + sx={{ + "& .MuiDataGrid-cell": { + alignItems: "center", + py: 1.25, + }, + }} + /> + + + + + + {isAdmin ? ( + + + + ) : null} + + {isAdmin ? ( + setIsCreateModalOpen(false)} + users={usersQuery.data ?? []} + roles={rolesQuery.data ?? []} + notificationTypes={notificationTypesQuery.data ?? []} + loading={createNotifications.isLoading} + onSubmit={(payload) => createNotifications.mutate(payload)} + /> + ) : null} diff --git a/migrations/20260317221411_create_notifications_table.down.sql b/migrations/20260317221411_create_notifications_table.down.sql new file mode 100644 index 00000000..bd2a4683 --- /dev/null +++ b/migrations/20260317221411_create_notifications_table.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS public.ix_notifications_notification_type_id; +DROP INDEX IF EXISTS public.ix_notifications_created_at; +DROP INDEX IF EXISTS public.ix_notifications_user_id_is_read; +DROP INDEX IF EXISTS public.ix_notifications_user_id; +DROP INDEX IF EXISTS public.ix_notifications_id; + +DROP TABLE IF EXISTS public.notifications; +DROP TABLE IF EXISTS public.notification_type_lu; diff --git a/migrations/20260317221411_create_notifications_table.up.sql b/migrations/20260317221411_create_notifications_table.up.sql new file mode 100644 index 00000000..d4f939f1 --- /dev/null +++ b/migrations/20260317221411_create_notifications_table.up.sql @@ -0,0 +1,54 @@ +CREATE TABLE public.notification_type_lu ( + id serial4 NOT NULL, + "name" varchar(50) NOT NULL, + description text NULL, + CONSTRAINT notification_type_lu_pkey PRIMARY KEY (id), + CONSTRAINT notification_type_lu_name_key UNIQUE ("name") +); + +INSERT INTO public.notification_type_lu ("name", description) VALUES + ('system', 'General system notification'), + ('warning', 'A Warning that may require user attention'), + ('message', 'A User-to-user message'), + ('approval', 'Approval required or granted notification'), + ('work_order', 'A work order update'), + ('owner_change', 'An ownership change'), + ('system_improvement', 'Notification about improvements, enhancements, or updates to the application'); + +CREATE TABLE public.notifications ( + id serial4 NOT NULL, + user_id int4 NOT NULL, + notification_type_id int4 NOT NULL, + title varchar(255) NOT NULL, + message text NOT NULL, + link varchar(500) NULL, + is_read bool NOT NULL DEFAULT false, + created_at timestamp NOT NULL DEFAULT now(), + read_at timestamp NULL, + CONSTRAINT notifications_pkey PRIMARY KEY (id), + CONSTRAINT fk_notifications_user + FOREIGN KEY (user_id) + REFERENCES public."Users"(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT fk_notifications_type + FOREIGN KEY (notification_type_id) + REFERENCES public.notification_type_lu(id) + ON DELETE RESTRICT + ON UPDATE CASCADE +); + +CREATE INDEX ix_notifications_id + ON public.notifications USING btree (id); + +CREATE INDEX ix_notifications_user_id + ON public.notifications USING btree (user_id); + +CREATE INDEX ix_notifications_user_id_is_read + ON public.notifications USING btree (user_id, is_read); + +CREATE INDEX ix_notifications_created_at + ON public.notifications USING btree (created_at); + +CREATE INDEX ix_notifications_notification_type_id + ON public.notifications USING btree (notification_type_id); From 49792ea5d1da5c689e610a6792cd18801c92f4f7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 17 Mar 2026 23:08:42 -0500 Subject: [PATCH 90/91] feat(Notification): Update the row styles --- frontend/src/views/Notifications.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx index e9212685..2342cabf 100644 --- a/frontend/src/views/Notifications.tsx +++ b/frontend/src/views/Notifications.tsx @@ -34,7 +34,6 @@ import { useGetNotifications, useGetNotificationTypes, useGetRoles, - useGetUnreadNotificationCount, useUpdateNotificationReadStatus, useGetUserAdminList, } from "@/service"; @@ -160,9 +159,9 @@ export const Notifications = () => { renderCell: (params) => ( ), }, @@ -199,14 +198,6 @@ export const Notifications = () => { ); }, }, - { - field: "read_at", - headerName: "Read At", - minWidth: 190, - flex: 1.1, - valueFormatter: (value) => - value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", - }, ], [], ); @@ -383,7 +374,11 @@ export const Notifications = () => { disableColumnMenu getRowHeight={() => "auto"} sx={{ + "& .MuiDataGrid-row": { + alignItems: "center", + }, "& .MuiDataGrid-cell": { + display: "flex", alignItems: "center", py: 1.25, }, From 8381e4548b669508ff1fc5d1b35c9a8ff3562fe4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 17 Mar 2026 23:22:02 -0500 Subject: [PATCH 91/91] feat(Notification): Add create_by column to table --- api/models/main_models.py | 20 +- api/routes/notifications.py | 12 +- api/schemas/notification_schemas.py | 3 + frontend/src/interfaces/Notification.ts | 3 + frontend/src/views/Notifications.tsx | 240 +++++++++++------- ...fications_table_add_create_by_col.down.sql | 7 + ...tifications_table_add_create_by_col.up.sql | 12 + 7 files changed, 197 insertions(+), 100 deletions(-) create mode 100644 migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql create mode 100644 migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql diff --git a/api/models/main_models.py b/api/models/main_models.py index 34c604b1..c289ced4 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -473,7 +473,15 @@ class Users(Base): redirect_page: Mapped[str] = mapped_column(String, nullable=True, default="/") avatar_img: Mapped[str] = mapped_column(String, nullable=True) notifications: Mapped[List["Notifications"]] = relationship( - "Notifications", back_populates="user", cascade="all, delete-orphan" + "Notifications", + back_populates="user", + cascade="all, delete-orphan", + foreign_keys="Notifications.user_id", + ) + created_notifications: Mapped[List["Notifications"]] = relationship( + "Notifications", + back_populates="creator", + foreign_keys="Notifications.created_by", ) @@ -501,6 +509,9 @@ class Notifications(Base): ), index=True, ) + created_by: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("Users.id", ondelete="SET NULL", onupdate="CASCADE"), index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) message: Mapped[str] = mapped_column(String, nullable=False) link: Mapped[Optional[str]] = mapped_column(String(500)) @@ -510,7 +521,12 @@ class Notifications(Base): ) read_at: Mapped[Optional[DateTime]] = mapped_column(DateTime) - user: Mapped["Users"] = relationship("Users", back_populates="notifications") + user: Mapped["Users"] = relationship( + "Users", back_populates="notifications", foreign_keys=[user_id] + ) + creator: Mapped[Optional["Users"]] = relationship( + "Users", back_populates="created_notifications", foreign_keys=[created_by] + ) notification_type: Mapped["NotificationTypeLU"] = relationship( "NotificationTypeLU", back_populates="notifications" ) diff --git a/api/routes/notifications.py b/api/routes/notifications.py index 8de59af5..0efdad02 100644 --- a/api/routes/notifications.py +++ b/api/routes/notifications.py @@ -39,7 +39,10 @@ def get_notifications( ): query_statement = ( select(Notifications) - .options(joinedload(Notifications.notification_type)) + .options( + joinedload(Notifications.notification_type), + joinedload(Notifications.creator), + ) .where(Notifications.user_id == user.id) .order_by(Notifications.created_at.desc(), Notifications.id.desc()) ) @@ -113,6 +116,7 @@ def get_unread_notification_count( def create_notifications( payload: NotificationCreateRequest, db: Session = Depends(get_db), + user: Users = Depends(get_current_user), ): user_ids = set(payload.user_ids) @@ -148,6 +152,7 @@ def create_notifications( Notifications( user_id=user_id, notification_type_id=payload.notification_type_id, + created_by=user.id, title=payload.title.strip(), message=payload.message.strip(), link=payload.link.strip() if payload.link else None, @@ -174,7 +179,10 @@ def update_notification_read_status( ): notification = db.scalar( select(Notifications) - .options(joinedload(Notifications.notification_type)) + .options( + joinedload(Notifications.notification_type), + joinedload(Notifications.creator), + ) .where(Notifications.id == payload.id, Notifications.user_id == user.id) ) diff --git a/api/schemas/notification_schemas.py b/api/schemas/notification_schemas.py index 41817662..c5a35ef0 100644 --- a/api/schemas/notification_schemas.py +++ b/api/schemas/notification_schemas.py @@ -1,6 +1,7 @@ from datetime import datetime from api.schemas.base import ORMBase +from api.schemas.security_schemas import User class NotificationType(ORMBase): @@ -11,6 +12,7 @@ class NotificationType(ORMBase): class Notification(ORMBase): user_id: int notification_type_id: int + created_by: int | None = None title: str message: str link: str | None = None @@ -18,6 +20,7 @@ class Notification(ORMBase): created_at: datetime read_at: datetime | None = None notification_type: NotificationType + creator: User | None = None class NotificationUnreadCount(ORMBase): diff --git a/frontend/src/interfaces/Notification.ts b/frontend/src/interfaces/Notification.ts index ead62883..feaac5b5 100644 --- a/frontend/src/interfaces/Notification.ts +++ b/frontend/src/interfaces/Notification.ts @@ -1,9 +1,11 @@ import { NotificationType } from "./NotificationType"; +import { User } from "./User"; export interface Notification { id: number; user_id: number; notification_type_id: number; + created_by?: number | null; title: string; message: string; link?: string | null; @@ -11,4 +13,5 @@ export interface Notification { created_at: string; read_at?: string | null; notification_type: NotificationType; + creator?: User | null; } diff --git a/frontend/src/views/Notifications.tsx b/frontend/src/views/Notifications.tsx index 2342cabf..bb12a4c1 100644 --- a/frontend/src/views/Notifications.tsx +++ b/frontend/src/views/Notifications.tsx @@ -26,8 +26,9 @@ import { CreateNotificationModal, CustomCardHeader, TristateToggle, + UserAvatar, } from "@/components"; -import { Notification, SecurityScope } from "@/interfaces"; +import { Notification, SecurityScope, User } from "@/interfaces"; import { Route } from "@/routes/notifications"; import { useCreateNotifications, @@ -37,6 +38,7 @@ import { useUpdateNotificationReadStatus, useGetUserAdminList, } from "@/service"; +import { getRoleLabel } from "@/utils/UserRoleGrouping"; const formatNotificationTypeName = (value: string) => value @@ -66,6 +68,8 @@ export const Notifications = () => { () => (notificationTypesQuery.data ?? []).map((type) => type.id), [notificationTypesQuery.data], ); + const getAvatarRole = (user: User | null | undefined) => + user ? getRoleLabel(user) : undefined; useEffect(() => { if (!notificationTypeIds.length || search.notification_type_id.length) @@ -107,99 +111,144 @@ export const Notifications = () => { }; const columns = useMemo[]>( - () => [ - { - field: "read_toggle", - headerName: "Mark Read", - minWidth: 110, - flex: 0.7, - sortable: false, - filterable: false, - renderCell: (params) => ( - - updateNotificationReadStatus.mutate({ - id: params.row.id, - is_read: checked, - }) - } - /> - ), - }, - { - field: "created_at", - headerName: "Created", - minWidth: 190, - flex: 1.1, - valueFormatter: (value) => - value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", - }, - { - field: "notification_type", - headerName: "Type", - minWidth: 140, - flex: 0.9, - sortable: false, - valueGetter: (_, row) => row.notification_type?.name ?? "", - renderCell: (params) => ( - - ), - }, - { - field: "is_read", - headerName: "Status", - minWidth: 110, - flex: 0.7, - renderCell: (params) => ( - - ), - }, - { - field: "title", - headerName: "Title", - minWidth: 220, - flex: 1.4, - }, - { - field: "message", - headerName: "Message", - minWidth: 320, - flex: 2.3, - }, - { - field: "link", - headerName: "Link", - minWidth: 180, - flex: 1.2, - sortable: false, - renderCell: (params) => { - const value = params.value as string | null | undefined; - if (!value) return "-"; + () => { + const baseColumns: GridColDef[] = [ + { + field: "read_toggle", + headerName: "Mark Read", + minWidth: 110, + flex: 0.7, + sortable: false, + filterable: false, + renderCell: (params) => ( + + updateNotificationReadStatus.mutate({ + id: params.row.id, + is_read: checked, + }) + } + /> + ), + }, + { + field: "created_at", + headerName: "Created", + minWidth: 190, + flex: 1.1, + valueFormatter: (value) => + value ? dayjs(value as string).format("MMMM D, YYYY h:mm A") : "-", + }, + { + field: "notification_type", + headerName: "Type", + minWidth: 140, + flex: 0.9, + sortable: false, + valueGetter: (_, row) => row.notification_type?.name ?? "", + renderCell: (params) => ( + + ), + }, + { + field: "is_read", + headerName: "Status", + minWidth: 110, + flex: 0.7, + renderCell: (params) => ( + + ), + }, + { + field: "title", + headerName: "Title", + minWidth: 220, + flex: 1.4, + }, + { + field: "message", + headerName: "Message", + minWidth: 320, + flex: 2.3, + }, + { + field: "link", + headerName: "Link", + minWidth: 180, + flex: 1.2, + sortable: false, + renderCell: (params) => { + const value = params.value as string | null | undefined; + if (!value) return "-"; - return ( - - Open - - ); + return ( + + Open + + ); + }, }, - }, - ], - [], + ]; + + if (!isAdmin) return baseColumns; + + return [ + baseColumns[0], + baseColumns[1], + { + field: "creator", + headerName: "Created By", + minWidth: 220, + flex: 1.3, + sortable: false, + cellClassName: "notification-creator-cell", + valueGetter: (_, row) => + row.creator?.display_name || row.creator?.full_name || "", + renderCell: (params) => { + const creator = params.row.creator; + if (!creator) return "-"; + + const name = creator.display_name || creator.full_name || "Unknown"; + + return ( + + + {name} + + ); + }, + }, + ...baseColumns.slice(2), + ]; + }, + [getAvatarRole, isAdmin, updateNotificationReadStatus], ); return ( @@ -374,12 +423,11 @@ export const Notifications = () => { disableColumnMenu getRowHeight={() => "auto"} sx={{ - "& .MuiDataGrid-row": { - alignItems: "center", + "& .notification-creator-cell": { + alignItems: "flex-start", + py: 1, }, "& .MuiDataGrid-cell": { - display: "flex", - alignItems: "center", py: 1.25, }, }} diff --git a/migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql b/migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql new file mode 100644 index 00000000..68e42a9f --- /dev/null +++ b/migrations/20260318040957_update_notifications_table_add_create_by_col.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS public.ix_notifications_created_by; + +ALTER TABLE public.notifications +DROP CONSTRAINT IF EXISTS fk_notifications_created_by; + +ALTER TABLE public.notifications +DROP COLUMN IF EXISTS created_by; diff --git a/migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql b/migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql new file mode 100644 index 00000000..88f069d6 --- /dev/null +++ b/migrations/20260318040957_update_notifications_table_add_create_by_col.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE public.notifications +ADD COLUMN created_by int4 NULL; + +ALTER TABLE public.notifications +ADD CONSTRAINT fk_notifications_created_by +FOREIGN KEY (created_by) +REFERENCES public."Users"(id) +ON DELETE SET NULL +ON UPDATE CASCADE; + +CREATE INDEX ix_notifications_created_by +ON public.notifications USING btree (created_by);