From 9e249ef4d5ad35cc6779a276423a6b75b5572b77 Mon Sep 17 00:00:00 2001 From: CC Date: Mon, 5 May 2025 13:32:35 -0600 Subject: [PATCH 001/146] Add migration for price in parts table --- migrations/20250505192557_add_price_field_in_Parts.down.sql | 2 ++ migrations/20250505192557_add_price_field_in_Parts.up.sql | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 migrations/20250505192557_add_price_field_in_Parts.down.sql create mode 100644 migrations/20250505192557_add_price_field_in_Parts.up.sql diff --git a/migrations/20250505192557_add_price_field_in_Parts.down.sql b/migrations/20250505192557_add_price_field_in_Parts.down.sql new file mode 100644 index 00000000..30b39627 --- /dev/null +++ b/migrations/20250505192557_add_price_field_in_Parts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Parts" +DROP COLUMN price; diff --git a/migrations/20250505192557_add_price_field_in_Parts.up.sql b/migrations/20250505192557_add_price_field_in_Parts.up.sql new file mode 100644 index 00000000..cffc9d42 --- /dev/null +++ b/migrations/20250505192557_add_price_field_in_Parts.up.sql @@ -0,0 +1,5 @@ +-- Add a new column named "price" to the "Parts" table +-- The field should come after the "vendor" field and be of type "decimal(10,2)" + +ALTER TABLE "Parts" +ADD COLUMN "price" DECIMAL(10,2); From c076ecfb20a79259f784f5edfef73aeb4a31033c Mon Sep 17 00:00:00 2001 From: Chris Cox Date: Wed, 7 May 2025 10:53:35 -0600 Subject: [PATCH 002/146] SQL to split price out of parts notes --- scripts/update_price_from_note.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 scripts/update_price_from_note.sql diff --git a/scripts/update_price_from_note.sql b/scripts/update_price_from_note.sql new file mode 100644 index 00000000..a2508649 --- /dev/null +++ b/scripts/update_price_from_note.sql @@ -0,0 +1,15 @@ +WITH extracted_prices AS ( + SELECT + id, + (REGEXP_MATCHES(note, '\$(?!.*:)([0-9]+\.[0-9]+)'))[1]::NUMERIC AS extracted_price + FROM "Parts" + WHERE note ~ '\$(?!.*:)[0-9]+\.[0-9]+' +) +UPDATE "Parts" +SET price = extracted_prices.extracted_price +FROM extracted_prices +WHERE "Parts".id = extracted_prices.id; + +UPDATE "Parts" +SET price = NULL +WHERE note NOT LIKE '%$%'; From e6c2f09f1a91901142065976955195b04f6bb600 Mon Sep 17 00:00:00 2001 From: CC Date: Mon, 12 May 2025 14:41:01 -0600 Subject: [PATCH 003/146] Add price to backend model and schema --- api/models/main_models.py | 1 + api/routes/parts.py | 1 + api/schemas/part_schemas.py | 1 + 3 files changed, 3 insertions(+) diff --git a/api/models/main_models.py b/api/models/main_models.py index 5fc7a208..8b763257 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -69,6 +69,7 @@ class Parts(Base): 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) + price: Mapped[Optional[float]] = mapped_column(Float) part_type_id: Mapped[int] = mapped_column( Integer, ForeignKey("PartTypeLU.id"), nullable=False diff --git a/api/routes/parts.py b/api/routes/parts.py index b66d8a4c..4c0eb07d 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -115,6 +115,7 @@ def create_part(new_part: part_schemas.Part, db: Session = Depends(get_db)): note=new_part.note, in_use=new_part.in_use, commonly_used=new_part.commonly_used, + price=new_part.price, ) try: diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 0243bb04..56561618 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -15,6 +15,7 @@ class Part(ORMBase): note: str | None = None in_use: bool commonly_used: bool + price: float | None = None part_type_id: int part_type: PartTypeLU | None = None From ad9cea0ce825b838d817a26148339f6083210d6f Mon Sep 17 00:00:00 2001 From: CC Date: Mon, 12 May 2025 15:10:27 -0600 Subject: [PATCH 004/146] Add price to parts UI and fix bug with saving --- api/routes/parts.py | 2 ++ api/schemas/part_schemas.py | 3 +-- frontend/src/views/Parts/PartDetailsCard.tsx | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 4c0eb07d..187aa60c 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -66,6 +66,8 @@ 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"]: + continue try: setattr(part_db, k, v) except AttributeError as e: diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index 56561618..cbb241b8 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -16,10 +16,9 @@ class Part(ORMBase): in_use: bool commonly_used: bool price: float | None = None - part_type_id: int - part_type: PartTypeLU | None = None + part_type: PartTypeLU | None = None meter_types: list[MeterTypeLU] | None = None diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index 61ad3552..da371f35 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -192,6 +192,15 @@ export default function PartDetailsCard({ helperText={errors?.count?.message} /> + + + Date: Wed, 14 May 2025 14:05:46 -0500 Subject: [PATCH 005/146] [App] Add /reports route --- frontend/src/App.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7197994a..d71ee9df 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { SnackbarProvider, enqueueSnackbar } from "notistack"; import { Grid } from "@mui/material"; import MonitoringWellsView from "./views/MonitoringWells/MonitoringWellsView"; -import ActivitiesView from "./views/Activities/ActivitiesView"; +import { ActivitiesView } from "./views/Activities/ActivitiesView"; import MetersView from "./views/Meters/MetersView"; import PartsView from "./views/Parts/PartsView"; import UserManagementView from "./views/UserManagement/UserManagementView"; @@ -22,11 +22,12 @@ import WellManagementView from "./views/WellManagement/WellManagementView"; import WorkOrdersView from "./views/WorkOrders/WorkOrdersView"; import Sidenav from "./sidenav"; -import Home from "./Home"; +import { Home } from "./Home"; import Topbar from "./components/Topbar"; import Login from "./login"; import { SecurityScope } from "./interfaces"; import ChloridesView from "./views/Chlorides/ChloridesView"; +import { ReportsView } from "./views/Reports"; // A wrapper that handles checking that the user is logged in and has any necessary scopes function AppLayout({ @@ -145,6 +146,16 @@ export default function App() { /> } /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> Date: Wed, 14 May 2025 14:06:21 -0500 Subject: [PATCH 006/146] [Home & Reports] Refactor Home view & init Reports view --- frontend/src/Home.tsx | 120 ++++++++++++++------------- frontend/src/views/Reports/index.tsx | 23 +++++ 2 files changed, 84 insertions(+), 59 deletions(-) create mode 100644 frontend/src/views/Reports/index.tsx diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index 57a6d45b..18773ff9 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -1,62 +1,64 @@ -import {Component} from "react"; -import {Box, Card, CardContent, CardHeader} from "@mui/material"; -import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; -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 { Box, Card, CardContent, CardHeader, 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"; -class Home extends Component{ - render() { +export const Home = () => { + const versionHistory = [ + "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.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", + ]; - const versionHistory = [ - "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.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 ( - -

Meter Manager Home

- - - PVACD Meter Manager Info - - - } - sx={{mb: 2, pb: 0}} - /> - - - - -

Version History

-
    - {versionHistory.map((version) => ( -
  • {version}
  • - ))} -
- - - - -
-
-
-
+ return ( + + + + Meter Manager Home + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + PVACD Meter Manager Info + Version History +
    + {versionHistory.map((version) => ( +
  • {version}
  • + ))} +
+ + + +
- ); - } -} - -export default Home; +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx new file mode 100644 index 00000000..bf313d3a --- /dev/null +++ b/frontend/src/views/Reports/index.tsx @@ -0,0 +1,23 @@ +import { Assessment } from "@mui/icons-material"; +import { Box, Card, CardContent, CardHeader } from "@mui/material"; + +export const ReportsView = () => { + return ( + + + + Reports + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + ); +}; From 14b982d95ecd9d252cbe3fde58a4a8273536cb18 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 14 May 2025 14:08:49 -0500 Subject: [PATCH 007/146] [/views] Reactor views to have consisent theming --- frontend/src/views/Chlorides/ChloridesView.tsx | 6 ++++-- frontend/src/views/Meters/MetersView.tsx | 3 +-- frontend/src/views/MonitoringWells/MonitoringWellsView.tsx | 6 ++++-- frontend/src/views/Parts/PartsView.tsx | 4 +--- frontend/src/views/UserManagement/UserManagementView.tsx | 4 +--- frontend/src/views/WellManagement/WellManagementView.tsx | 4 +--- 6 files changed, 12 insertions(+), 15 deletions(-) diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 22bda825..62baf7b5 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -178,8 +178,10 @@ export default function ChloridesView() { }; return ( - - + + diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index 2a7953e2..a9188da2 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -32,8 +32,7 @@ export default function MetersView() { }, [selectedMeter]); return ( - -

Meter Information

+ - + + diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index 2362cc31..4aa7885c 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -22,9 +22,7 @@ export default function PartsView() { }, [selectedMeterType]); return ( - -

Manage Parts

- + -

Manage Users

- + -

Manage Wells

- + Date: Wed, 14 May 2025 14:09:59 -0500 Subject: [PATCH 008/146] [Work Orders] Refactor page's style & action buttons --- .../src/views/WorkOrders/WorkOrdersTable.tsx | 761 ++++++++++-------- .../src/views/WorkOrders/WorkOrdersView.tsx | 6 +- 2 files changed, 441 insertions(+), 326 deletions(-) diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index 8ec9497a..b4750e2e 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -1,353 +1,466 @@ -/* -This is the work orders table. -I anticipate this component will be self-contained including the ability to add a new row. -*/ - -import React, { useEffect, useState } from 'react'; -import DeletedIcon from '@mui/icons-material/Delete'; -import AddIcon from '@mui/icons-material/Add'; -import HandymanIcon from '@mui/icons-material/Handyman'; -import { - DataGrid, - GridColDef, - GridRowModel, - GridActionsCellItem, - GridActionsCellItemProps, - GridRowParams, - GridRowId, - GridFilterItem -} from '@mui/x-data-grid'; -import { useGetWorkOrders, useUpdateWorkOrder, useGetUserList, useDeleteWorkOrder, useCreateWorkOrder } from '../../service/ApiServiceNew'; -import { WorkOrderStatus } from '../../enums'; -import MeterSelection from '../../components/MeterSelection'; -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField } from '@mui/material'; -import GridFooterWithButton from '../../components/GridFooterWithButton'; -import { MeterActivity, MeterListDTO, NewWorkOrder, SecurityScope } from '../../interfaces'; -import { useAuthUser } from 'react-auth-kit'; -import { Link, createSearchParams } from 'react-router-dom'; +import { useEffect, useState } from "react"; +import DeletedIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import HandymanIcon from "@mui/icons-material/Handyman"; +import { + DataGrid, + GridColDef, + GridRowModel, + GridActionsCellItem, + GridActionsCellItemProps, + GridRenderCellParams, + GridRowId, + GridFilterItem, +} from "@mui/x-data-grid"; +import { + useGetWorkOrders, + useUpdateWorkOrder, + useGetUserList, + useDeleteWorkOrder, + useCreateWorkOrder, +} from "../../service/ApiServiceNew"; +import { WorkOrderStatus } from "../../enums"; +import MeterSelection from "../../components/MeterSelection"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + TextField, +} from "@mui/material"; +import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { + MeterActivity, + MeterListDTO, + NewWorkOrder, + SecurityScope, +} from "../../interfaces"; +import { useAuthUser } from "react-auth-kit"; +import { Link, createSearchParams } from "react-router-dom"; function DeleteWorkOrder({ - deleteUser, - deleteMessage, - ...props - }: GridActionsCellItemProps & { deleteUser: () => void, deleteMessage?: string}) { - const [open, setOpen] = React.useState(false); - - return ( - - setOpen(true)} /> - setOpen(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - {deleteMessage} - - - This action cannot be undone. - - - - - - - - - ); + deleteUser, + deleteMessage, + ...props +}: GridActionsCellItemProps & { + deleteUser: () => void; + deleteMessage?: string; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} /> + setOpen(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {deleteMessage} + + + This action cannot be undone. + + + + + + + + + ); } interface NewWorkOrderModalProps { - openNewWorkOrderModal: boolean, - closeNewWorkOrderModal: () => void, - submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void + openNewWorkOrderModal: boolean; + closeNewWorkOrderModal: () => void; + submitNewWorkOrder: (newWorkOrder: NewWorkOrder) => void; } -function NewWorkOrderModal({openNewWorkOrderModal, closeNewWorkOrderModal, submitNewWorkOrder}: NewWorkOrderModalProps) { - const [workOrderTitle, setWorkOrderTitle] = useState(''); - const [workOrderMeter, setWorkOrderMeter] = useState(); - const [meterSelectionError, setMeterSelectionError] = useState(false); - const [titleError, setTitleError] = useState(false); +function NewWorkOrderModal({ + openNewWorkOrderModal, + closeNewWorkOrderModal, + submitNewWorkOrder, +}: NewWorkOrderModalProps) { + const [workOrderTitle, setWorkOrderTitle] = useState(""); + const [workOrderMeter, setWorkOrderMeter] = useState< + MeterListDTO | undefined + >(); + const [meterSelectionError, setMeterSelectionError] = + useState(false); + const [titleError, setTitleError] = useState(false); - function handleSubmit() { - if (!workOrderMeter) { - setMeterSelectionError(true); - return - } - if (!workOrderTitle) { - setTitleError(true); - return - } + function handleSubmit() { + if (!workOrderMeter) { + setMeterSelectionError(true); + return; + } + if (!workOrderTitle) { + setTitleError(true); + return; + } - //If both fields are filled, submit the work order - //Create a new work order object - const newWorkOrder: NewWorkOrder = { - date_created: new Date(), - meter_id: workOrderMeter.id, - title: workOrderTitle - } - submitNewWorkOrder(newWorkOrder); - closeNewWorkOrderModal(); + //If both fields are filled, submit the work order + //Create a new work order object + const newWorkOrder: NewWorkOrder = { + date_created: new Date(), + meter_id: workOrderMeter.id, + title: workOrderTitle, + }; + submitNewWorkOrder(newWorkOrder); + closeNewWorkOrderModal(); - //Reset the form - setWorkOrderMeter(undefined); - setWorkOrderTitle(''); - } + //Reset the form + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + } - function handleCancel() { - closeNewWorkOrderModal(); - setWorkOrderMeter(undefined); - setWorkOrderTitle(''); - } - - return ( - - Create a New Work Order - - - To create a new work order, please select a meter and title. Other fields can be edited as needed after creation. - - - setWorkOrderTitle(event.target.value)} - error={titleError} - helperText={titleError ? "Title cannot be empty" : ""} - /> - - - - - - - ) + const handleCancel = () => { + closeNewWorkOrderModal(); + setWorkOrderMeter(undefined); + setWorkOrderTitle(""); + }; + + return ( + + Create a New Work Order + + + To create a new work order, please select a meter and title. Other + fields can be edited as needed after creation. + + + setWorkOrderTitle(event.target.value)} + error={titleError} + helperText={titleError ? "Title cannot be empty" : ""} + /> + + + + + + + ); } export default function WorkOrdersTable() { - const [workOrderFilters, setWorkOrderFilters] = useState([WorkOrderStatus.Open, WorkOrderStatus.Review]); - const workOrderList = useGetWorkOrders(workOrderFilters); - const updateWorkOrder = useUpdateWorkOrder(); - const deleteWorkOrder = useDeleteWorkOrder(()=>console.log("Work order deleted")); - const createWorkOrder = useCreateWorkOrder(); - const userList = useGetUserList(); + const [workOrderFilters, setWorkOrderFilters] = useState([ + WorkOrderStatus.Open, + WorkOrderStatus.Review, + ]); + const workOrderList = useGetWorkOrders(workOrderFilters); + const updateWorkOrder = useUpdateWorkOrder(); + const deleteWorkOrder = useDeleteWorkOrder(() => + console.log("Work order deleted"), + ); + const createWorkOrder = useCreateWorkOrder(); + const userList = useGetUserList(); - const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = useState(false); + const [isNewWorkOrderModalOpen, setIsNewWorkOrderModalOpen] = + useState(false); - //Current user needed for various changes to UI based on user role - const authUser = useAuthUser() - const hasAdminScope = authUser()?.user_role.security_scopes.map((scope: SecurityScope) => scope.scope_string).includes('admin') - const current_user_name = getUserFromID(authUser()?.id) - var initialFilter: GridFilterItem[] = [] //No filter if admin - var status_options = ['Open', 'Review', 'Closed']; + //Current user needed for various changes to UI based on user role + const authUser = useAuthUser(); + const hasAdminScope = authUser() + ?.user_role.security_scopes.map( + (scope: SecurityScope) => scope.scope_string, + ) + .includes("admin"); - //Change a few defaults depending on if admin or not - if (!hasAdminScope){ - initialFilter = [{field: 'assigned_user_id', operator: 'is', value: current_user_name}]; - status_options = ['Open', 'Review']; - }else{ - //Filter by Status - //Unlike with the technicians, this filters on the frontend in case the admin wants to see all work orders - initialFilter = [{field: 'status', operator: 'not', value: 'Closed'}]; - } + const getUserFromID = (id: number | undefined) => { + return userList.data?.find((user) => user.id === id)?.full_name ?? ""; + }; - //Refresh work order list once a minute - useEffect(() => { - const interval = setInterval(() => { - workOrderList.refetch(); - }, 60000); - return () => clearInterval(interval); - }, [workOrderList]); - - //Update list of work orders if technician level to only show open and review. - //useEffect prevents this from running on every render - useEffect(() => { - if (hasAdminScope) { - setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review, WorkOrderStatus.Closed]); - } else { - setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review]); - } - }, [hasAdminScope]); // Dependency array ensures this runs only when hasAdminScope changes + const getUserIDfromName = (name: string) => { + return userList.data?.find((user) => user.full_name === name)?.id ?? 0; + }; - function getUserFromID(id: number|undefined) { - return userList.data?.find(user => user.id === id)?.full_name ?? ""; - } - - function getUserIDfromName(name: string) { - return userList.data?.find(user => user.full_name === name)?.id ?? 0; - } + const current_user_name = getUserFromID(authUser()?.id); + var initialFilter: GridFilterItem[] = []; //No filter if admin + var status_options = ["Open", "Review", "Closed"]; - function handleRowUpdate(updatedRow: GridRowModel, originalRow: GridRowModel): Promise { - //Determine what field has changed and update the work order - const updatedField = Object.keys(updatedRow).find(key => updatedRow[key] !== originalRow[key]); - let field_data = null; - - //If field is assigned_user_id, convert the name to an id - if (updatedField === 'assigned_user_id') { - field_data = getUserIDfromName(updatedRow.assigned_user_id as string); - } else { - field_data = updatedRow[updatedField as string]; - } - - const work_order_update = {work_order_id: updatedRow.work_order_id, [updatedField as string]: field_data}; - console.log("Updating work order", work_order_update); + //Change a few defaults depending on if admin or not + if (!hasAdminScope) { + initialFilter = [ + { field: "assigned_user_id", operator: "is", value: current_user_name }, + ]; + status_options = ["Open", "Review"]; + } else { + //Filter by Status + //Unlike with the technicians, this filters on the frontend in case the admin wants to see all work orders + initialFilter = [{ field: "status", operator: "not", value: "Closed" }]; + } - //Create a promise to update the work order - return updateWorkOrder.mutateAsync(work_order_update) - } + //Refresh work order list once a minute + useEffect(() => { + const interval = setInterval(() => { + workOrderList.refetch(); + }, 60000); + return () => clearInterval(interval); + }, [workOrderList]); - function handleProcessRowUpdateError(error: Error): void { - console.error("Error updating work order", error); - } - - function handleDeleteClick(id: GridRowId) { - let deletepromise = deleteWorkOrder.mutateAsync(id as number); - deletepromise.then(() => { - //Get the updated rows - workOrderList.refetch(); - console.log("Work order deleted"); - }); + //Update list of work orders if technician level to only show open and review. + //useEffect prevents this from running on every render + useEffect(() => { + if (hasAdminScope) { + setWorkOrderFilters([ + WorkOrderStatus.Open, + WorkOrderStatus.Review, + WorkOrderStatus.Closed, + ]); + } else { + setWorkOrderFilters([WorkOrderStatus.Open, WorkOrderStatus.Review]); } + }, [hasAdminScope]); // Dependency array ensures this runs only when hasAdminScope changes + + const handleRowUpdate = ( + updatedRow: GridRowModel, + originalRow: GridRowModel, + ): Promise => { + //Determine what field has changed and update the work order + const updatedField = Object.keys(updatedRow).find( + (key) => updatedRow[key] !== originalRow[key], + ); + let field_data = null; - function handleNewWorkOrder(newWorkOrder: NewWorkOrder) { - console.log("Creating new work order", newWorkOrder); - createWorkOrder.mutateAsync(newWorkOrder).then(() => { - //Get the updated rows - workOrderList.refetch(); - console.log("Work order created"); - }); + //If field is assigned_user_id, convert the name to an id + if (updatedField === "assigned_user_id") { + field_data = getUserIDfromName(updatedRow.assigned_user_id as string); + } else { + field_data = updatedRow[updatedField as string]; } - // Define the columns for the table - const columns: GridColDef[] = [ - { field: 'work_order_id', headerName: 'ID', width: 50 }, //Note next line... for some reason this value comes in from the API as a string, not a date - { field: 'date_created', headerName: 'Date', width: 100, valueGetter: (value) => new Date(value), valueFormatter: (value: Date) => value.toLocaleDateString()}, - { - field: 'meter_serial', - headerName: 'Meter', - width: 100, - renderCell: (params) => { - return {params.value} - } - }, - { field: 'title', headerName: 'Title', width: 200, editable: hasAdminScope}, - { field: 'description', headerName: 'Description', width: 300, editable: hasAdminScope}, - { field: 'creator', headerName: 'Created By', width: 150, editable: hasAdminScope}, - { field: 'status', headerName: 'Status', width: 125, type: 'singleSelect', valueOptions: status_options, editable: true}, - { field: 'notes', headerName: 'Notes', width: 300, editable: true}, - { - field: 'associated_activities', - headerName: 'Activity IDs', - width: 150, - renderCell: (params) => { - const activities = params.value as MeterActivity[] ?? []; - const links = activities.map((activity, index) => ( - - - {activity.id} - - {index < params.value.length - 1 ? ', ' : ''} - - )); - return <>{links}; - }, - editable: false - }, - { - field: 'assigned_user_id', - headerName: 'Technician Assigned', - width: 200, - valueGetter: (id) => getUserFromID(id as number), - type: 'singleSelect', - valueOptions: userList.data?.map(user => user.full_name) ?? [], - editable: hasAdminScope - }, - { - field: 'actions', - headerName: 'Actions', - width: 100, - type: 'actions', - getActions: (params: GridRowParams) => { - return params.row.status === 'Open' ? [ - , - } - deleteMessage={`Delete work order ${params.id}?`} - label="Delete" - deleteUser={() => handleDeleteClick(params.id)} - showInMenu={false} - disabled={hasAdminScope ? false : true} - />, - ]:[ - } - deleteMessage={`Delete work order ${params.id}?`} - label="Delete" - deleteUser={() => handleDeleteClick(params.id)} - showInMenu={false} - disabled={hasAdminScope ? false : true} - />, - ]; - } - }, - ]; + const work_order_update = { + work_order_id: updatedRow.work_order_id, + [updatedField as string]: field_data, + }; + + //Create a promise to update the work order + return updateWorkOrder.mutateAsync(work_order_update); + }; + + const handleProcessRowUpdateError = (error: Error): void => { + console.error("Error updating work order", error); + }; + + const handleDeleteClick = (id: GridRowId) => { + let deletepromise = deleteWorkOrder.mutateAsync(id as number); + deletepromise.then(() => { + //Get the updated rows + workOrderList.refetch(); + }); + }; + + const handleNewWorkOrder = (newWorkOrder: NewWorkOrder) => { + createWorkOrder.mutateAsync(newWorkOrder).then(() => { + //Get the updated rows + workOrderList.refetch(); + }); + }; - return ( -
- 'auto'} - getRowId={(row) => row.work_order_id} - columns={columns} - initialState={ - { - columns: {columnVisibilityModel: { - work_order_id: false, - creator: hasAdminScope, - associated_activities: hasAdminScope, - assigned_user_id: hasAdminScope - }}, - filter: {filterModel: {items: initialFilter}}, - } + // Define the columns for the table + const columns: GridColDef[] = [ + { field: "work_order_id", headerName: "ID", width: 50 }, //Note next line... for some reason this value comes in from the API as a string, not a date + { + field: "date_created", + headerName: "Date", + width: 100, + valueGetter: (value) => new Date(value), + valueFormatter: (value: Date) => value.toLocaleDateString(), + }, + { + field: "meter_serial", + headerName: "Meter", + width: 100, + renderCell: (params) => { + return ( + + {params.value} + + ); + }, + }, + { + field: "title", + headerName: "Title", + width: 200, + editable: hasAdminScope, + }, + { + field: "description", + headerName: "Description", + width: 300, + editable: hasAdminScope, + }, + { + field: "creator", + headerName: "Created By", + width: 150, + editable: hasAdminScope, + }, + { + field: "status", + headerName: "Status", + width: 125, + type: "singleSelect", + valueOptions: status_options, + editable: true, + }, + { field: "notes", headerName: "Notes", width: 300, editable: true }, + { + field: "associated_activities", + headerName: "Activity IDs", + width: 150, + renderCell: (params) => { + const activities = (params.value as MeterActivity[]) ?? []; + const links = activities.map((activity, index) => ( + + + {activity.id} + + {index < params.value.length - 1 ? ", " : ""} + + )); + return <>{links}; + }, + editable: false, + }, + { + field: "assigned_user_id", + headerName: "Technician Assigned", + width: 200, + valueGetter: (id) => getUserFromID(id as number), + type: "singleSelect", + valueOptions: userList.data?.map((user) => user.full_name) ?? [], + editable: hasAdminScope, + }, + { + field: "actions", + headerName: "Actions", + width: 100, + sortable: false, + renderCell: (params: GridRenderCellParams) => { + const isOpen = params.row.status === "Open"; + + return ( + + {isOpen && ( + setIsNewWorkOrderModalOpen(true)}> - Add a New Work Order - - }}} - /> - setIsNewWorkOrderModalOpen(false)} - submitNewWorkOrder={handleNewWorkOrder} + aria-label="Edit Activity" + > + + + )} + } + deleteMessage={`Delete work order ${params.id}?`} + label="Delete" + deleteUser={() => handleDeleteClick(params.id)} + showInMenu={false} + disabled={!hasAdminScope} /> -
- ); -}; +
+ ); + }, + }, + ]; + + return ( +
+ "auto"} + getRowId={(row) => row.work_order_id} + columns={columns} + initialState={{ + columns: { + columnVisibilityModel: { + work_order_id: false, + creator: hasAdminScope, + associated_activities: hasAdminScope, + assigned_user_id: hasAdminScope, + }, + }, + filter: { filterModel: { items: initialFilter } }, + }} + processRowUpdate={handleRowUpdate} + onProcessRowUpdateError={handleProcessRowUpdateError} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: hasAdminScope && ( + + ), + }, + }} + /> + setIsNewWorkOrderModalOpen(false)} + submitNewWorkOrder={handleNewWorkOrder} + /> +
+ ); +} diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx index ff2f635f..b74db399 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersView.tsx @@ -5,8 +5,10 @@ import WorkOrdersTable from "./WorkOrdersTable"; export default function WorkOrdersView() { return ( - - + + From ea67024b1b2fcfa053d911c5f65342b20a6cf4d0 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 14 May 2025 14:10:56 -0500 Subject: [PATCH 009/146] [Activities] Refactor pages style & rm tabs --- .../src/views/Activities/ActivitiesView.tsx | 56 ++++++++++--------- .../MeterActivitySelection.tsx | 7 --- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index 976b42c6..003b2671 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,35 +1,41 @@ -import { useState } from "react"; -import { Box, Grid, CardContent, Card } from "@mui/material"; -import TabPanel from "../../components/TabPanel"; +import { + Box, + Grid, + CardContent, + Card, + CardHeader, + Typography, +} from "@mui/material"; import MeterActivityEntry from "./MeterActivityEntry/MeterActivityEntry"; +import { Construction } from "@mui/icons-material"; export const gridBreakpoints = { xs: 12 }; export const toggleStyle = { "&.Mui-selected": { borderColor: "blue", border: 1 }, }; -export default function ActivitiesView() { - const [currentTabIndex, _] = useState(0); - +export const ActivitiesView = () => { return ( - -

- Submit an Activity -

- - - - - - - - -
Not Yet Implemented
-
-
-
-
-
+ + + + Submit an Activity + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + ); -} +}; diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx index 89a6103a..a34539a6 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivitySelection.tsx @@ -10,13 +10,6 @@ import { ControlledWorkOrderSelect } from "../../../components/RHControlled/Cont export function MeterActivitySelection({ control, errors, setValue }: any) { return ( -

- Activity Details -

- Date: Wed, 14 May 2025 14:11:32 -0500 Subject: [PATCH 010/146] [sidenav] Update links, text, & icons --- frontend/src/sidenav.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index ba33c1db..1cabc505 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -9,6 +9,7 @@ import { WorkOrder } from "./interfaces"; import "./sidenav.css"; import { + Assessment, Build, Construction, FormatListBulletedOutlined, @@ -101,18 +102,23 @@ export default function Sidenav() { label={workOrderLabel} Icon={FormatListBulletedOutlined} /> - + - + + {hasAdminScope && ( <>
Admin Management
- - + + )} From 7ad9b89e36b276e59415176f237a29fa958bac99 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 14 May 2025 15:14:24 -0500 Subject: [PATCH 011/146] [NavLink] Refactor to its own component --- frontend/src/components/NavLink.tsx | 29 +++++++++++++++++++++ frontend/src/sidenav.tsx | 34 +++---------------------- frontend/src/views/Reports/index.tsx | 38 ++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/NavLink.tsx diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx new file mode 100644 index 00000000..c9a8b47a --- /dev/null +++ b/frontend/src/components/NavLink.tsx @@ -0,0 +1,29 @@ +import { Grid, SvgIconProps } from "@mui/material"; +import TableViewIcon from "@mui/icons-material/TableView"; +import { Link } from "react-router-dom"; + +export const NavLink = ({ + route, + label, + Icon, +}: { + route: string; + label: string; + Icon?: React.ComponentType; +}) => { + return ( + + + {Icon ? ( + + ) : ( + + )} +
{label}
+ +
+ ); +}; diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index 1cabc505..d742ddbb 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,8 +1,6 @@ -import React, { useEffect, useState } from "react"; -import TableViewIcon from "@mui/icons-material/TableView"; -import { Link, useLocation } from "react-router-dom"; +import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { Grid, SvgIconProps } from "@mui/material"; +import { Grid } from "@mui/material"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { WorkOrder } from "./interfaces"; @@ -20,9 +18,9 @@ import { Science, ScreenshotMonitor, } from "@mui/icons-material"; +import { NavLink } from "./components/NavLink"; export default function Sidenav() { - let location = useLocation(); const authUser = useAuthUser(); const hasAdminScope = authUser() ?.user_role.security_scopes.map((scope: any) => scope.scope_string) @@ -54,32 +52,6 @@ export default function Sidenav() { return () => clearInterval(interval); }, []); - const NavLink = ({ - route, - label, - Icon, - }: { - route: string; - label: string; - Icon?: React.ComponentType; - }) => { - return ( - - - {Icon ? ( - - ) : ( - - )} -
{label}
- -
- ); - }; - return ( { return ( @@ -16,7 +25,32 @@ export const ReportsView = () => { } sx={{ mb: 0, pb: 0 }} /> - + + + + + + + + + +
); From 7b6382b5d9385136a70387a565769f0ac8d32c98 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 16 May 2025 09:53:21 -0500 Subject: [PATCH 012/146] [/Reports] Add init reports --- frontend/src/App.tsx | 66 +++++++++++++++++++ frontend/src/views/Reports/Board/index.tsx | 51 ++++++++++++++ .../src/views/Reports/Chlorides/index.tsx | 51 ++++++++++++++ .../src/views/Reports/Inventory/index.tsx | 51 ++++++++++++++ .../views/Reports/MonitoringWells/index.tsx | 51 ++++++++++++++ frontend/src/views/Reports/Repairs/index.tsx | 51 ++++++++++++++ .../src/views/Reports/WorkOrders/index.tsx | 55 ++++++++++++++++ 7 files changed, 376 insertions(+) create mode 100644 frontend/src/views/Reports/Board/index.tsx create mode 100644 frontend/src/views/Reports/Chlorides/index.tsx create mode 100644 frontend/src/views/Reports/Inventory/index.tsx create mode 100644 frontend/src/views/Reports/MonitoringWells/index.tsx create mode 100644 frontend/src/views/Reports/Repairs/index.tsx create mode 100644 frontend/src/views/Reports/WorkOrders/index.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d71ee9df..228210d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,12 @@ import Login from "./login"; import { SecurityScope } from "./interfaces"; import ChloridesView from "./views/Chlorides/ChloridesView"; import { ReportsView } from "./views/Reports"; +import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; +import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; +import { RepairsReportView } from "./views/Reports/Repairs"; +import { InventoryReportView } from "./views/Reports/Inventory"; +import { BoardReportView } from "./views/Reports/Board"; +import { ChloridesReportView } from "./views/Reports/Chlorides"; // A wrapper that handles checking that the user is logged in and has any necessary scopes function AppLayout({ @@ -156,6 +162,66 @@ export default function App() { /> } /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> + } + requiredScopes={["read"]} + setErrorMessage={setErrorMessage} + /> + } + /> { + 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 new file mode 100644 index 00000000..717020a4 --- /dev/null +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -0,0 +1,51 @@ +import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; +import { + Box, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; + +export const ChloridesReportView = () => { + return ( + + + + Chlorides Report + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx new file mode 100644 index 00000000..19058f63 --- /dev/null +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -0,0 +1,51 @@ +import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; +import { + Box, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; + +export const InventoryReportView = () => { + return ( + + + + Inventory Report + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx new file mode 100644 index 00000000..34acec6d --- /dev/null +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -0,0 +1,51 @@ +import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; +import { + Box, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; + +export const MonitoringWellsReportView = () => { + return ( + + + + Monitoring Wells Report + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/Repairs/index.tsx b/frontend/src/views/Reports/Repairs/index.tsx new file mode 100644 index 00000000..cc07b98a --- /dev/null +++ b/frontend/src/views/Reports/Repairs/index.tsx @@ -0,0 +1,51 @@ +import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; +import { + Box, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; + +export const RepairsReportView = () => { + return ( + + + + Repairs Report + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/WorkOrders/index.tsx b/frontend/src/views/Reports/WorkOrders/index.tsx new file mode 100644 index 00000000..f8f2ee07 --- /dev/null +++ b/frontend/src/views/Reports/WorkOrders/index.tsx @@ -0,0 +1,55 @@ +import { + FormatListBulletedOutlined, + ArrowBack, + PictureAsPdf, +} from "@mui/icons-material"; +import { + Box, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; + +export const WorkOrdersReportView = () => { + return ( + + + + Work Orders Report + + + } + sx={{ mb: 0, pb: 0 }} + /> + + + + + + + + + + + + + + + + + + + + + + + ); +}; From 9ee10514f272027c7cd32401f5c1dc4e150afc31 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 20 May 2025 11:43:10 -0500 Subject: [PATCH 013/146] [/Reports] Add forms to reports pages --- frontend/src/views/Reports/Board/index.tsx | 58 ++++++++ .../src/views/Reports/Chlorides/index.tsx | 58 ++++++++ .../src/views/Reports/Inventory/index.tsx | 90 ++++++++++++ .../views/Reports/MonitoringWells/index.tsx | 90 ++++++++++++ frontend/src/views/Reports/Repairs/index.tsx | 135 ++++++++++++++++++ .../src/views/Reports/WorkOrders/index.tsx | 105 ++++++++++++++ frontend/src/views/Reports/index.tsx | 2 +- 7 files changed, 537 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/Reports/Board/index.tsx b/frontend/src/views/Reports/Board/index.tsx index a50c2f48..f0a941d0 100644 --- a/frontend/src/views/Reports/Board/index.tsx +++ b/frontend/src/views/Reports/Board/index.tsx @@ -1,6 +1,7 @@ import { ArrowBack, People, PictureAsPdf } from "@mui/icons-material"; import { Box, + Button, Card, CardContent, CardHeader, @@ -9,8 +10,28 @@ import { 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 ( {
+ + + + + + + + + + + + + +
diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 717020a4..7cc8fd25 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,6 +1,7 @@ import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; import { Box, + Button, Card, CardContent, CardHeader, @@ -9,8 +10,28 @@ import { 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 ChloridesReportView = () => { + const { control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + return ( {
+ + + + + + + + + + + + + + diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx index 19058f63..0db71b68 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -1,16 +1,47 @@ import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; import { Box, + Button, Card, CardContent, CardHeader, Grid, IconButton, + TextField, Tooltip, } 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 { useQuery } from "react-query"; +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"), + parts: yup.string().required("At least one Part is required"), +}); + +const defaultSchema = { + from: dayjs(), + to: dayjs(), + parts: "", +}; export const InventoryReportView = () => { + const partsQuery = useQuery({ + queryKey: ["Inventory", "report", "parts"], + queryFn: async () => {}, + }); + + const { control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + return ( { + + + + + + + + + { + if (partsQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + + + + + + diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 34acec6d..b2f81f72 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -1,16 +1,47 @@ import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; import { Box, + Button, Card, CardContent, CardHeader, Grid, IconButton, + TextField, Tooltip, } 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 { useQuery } from "react-query"; +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"), + wells: yup.string().required("At least one Well is required"), +}); + +const defaultSchema = { + from: dayjs(), + to: dayjs(), + wells: "", +}; export const MonitoringWellsReportView = () => { + const wellsQuery = useQuery({ + queryKey: ["MonitoringWells", "report", "wells"], + queryFn: async () => {}, + }); + + const { control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + return ( { + + + + + + + + + { + if (wellsQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + + + + + + diff --git a/frontend/src/views/Reports/Repairs/index.tsx b/frontend/src/views/Reports/Repairs/index.tsx index cc07b98a..a690080b 100644 --- a/frontend/src/views/Reports/Repairs/index.tsx +++ b/frontend/src/views/Reports/Repairs/index.tsx @@ -1,16 +1,59 @@ import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; import { Box, + Button, Card, CardContent, CardHeader, Grid, IconButton, + TextField, Tooltip, } 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 { useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import dayjs from "dayjs"; + +const schema = yup.object().shape({ + time: yup.mixed().nullable().required("Date is required"), + techician: yup.string().required("Techician is required"), + meters: yup.string().required("At least one Meter is required"), + locations: yup.string().required("At least one Location is required"), +}); + +const defaultSchema = { + time: dayjs(), + techician: "", + meters: "", + locations: "", +}; export const RepairsReportView = () => { + const techiciansQuery = useQuery({ + queryKey: ["Repairs", "report", "techicians"], + queryFn: async () => {}, + }); + + const metersQuery = useQuery({ + queryKey: ["Repairs", "report", "meters"], + queryFn: async () => {}, + }); + + const locationsQuery = useQuery({ + queryKey: ["Repairs", "report", "locations"], + queryFn: async () => {}, + }); + + const { control, reset } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + return ( { + + + + + + { + if (techiciansQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + { + if (metersQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + { + if (locationsQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + /> + + + + + + + + diff --git a/frontend/src/views/Reports/WorkOrders/index.tsx b/frontend/src/views/Reports/WorkOrders/index.tsx index f8f2ee07..b2b8f9e0 100644 --- a/frontend/src/views/Reports/WorkOrders/index.tsx +++ b/frontend/src/views/Reports/WorkOrders/index.tsx @@ -5,16 +5,51 @@ import { } from "@mui/icons-material"; import { Box, + Button, Card, CardContent, CardHeader, Grid, IconButton, + TextField, Tooltip, } 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 { useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import dayjs from "dayjs"; + +const schema = yup.object().shape({ + time: yup.mixed().nullable().required("Date is required"), + techician: yup.string().required("Techician is required"), + source: yup.string().required("Source is required"), +}); + +const defaultSchema = { + time: dayjs(), + techician: "", + source: "", +}; export const WorkOrdersReportView = () => { + 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 ( { + + + + + + { + 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 b7fd62f5..62cb00be 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -26,7 +26,7 @@ export const ReportsView = () => { sx={{ mb: 0, pb: 0 }} /> - + Date: Tue, 20 May 2025 11:43:47 -0500 Subject: [PATCH 014/146] [App][Topbar] Update app's layout --- frontend/src/App.tsx | 28 +++++++++++++++++++++------- frontend/src/components/Topbar.tsx | 21 +++++++++++++-------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 228210d8..599280cc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,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 { Grid } from "@mui/material"; +import { Box, Grid } from "@mui/material"; import MonitoringWellsView from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; @@ -72,14 +72,28 @@ function AppLayout({ - - + + - - + + {pageComponent} - - + + ); return null; diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 70b997ae..9aa6ce5f 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -28,8 +28,20 @@ export default function Topbar() { navigate("/home")} > Meter Manager @@ -74,13 +86,6 @@ const styles = { py: 1, boxShadow: "3px 2px 5px -2px rgba(0,0,0,0.2)", }, - logo: { - fontWeight: "bold", - fontSize: "32px", - color: "darkblue", - cursor: "pointer", - marginLeft: "10px", - }, button: { marginTop: "auto", marginBottom: "auto", From d84f2b0708e01aa4d43a765f29d01a1163bad4a8 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 21 May 2025 20:54:47 -0500 Subject: [PATCH 015/146] [index] First attempt at new autocomplete --- .../src/views/Reports/Inventory/index.tsx | 131 ++++++++++++++---- 1 file changed, 102 insertions(+), 29 deletions(-) diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx index 0db71b68..54a7a78d 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -1,5 +1,6 @@ import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; import { + Autocomplete, Box, Button, Card, @@ -12,36 +13,89 @@ import { } 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 { Controller, useForm } from "react-hook-form"; import { useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; -import dayjs from "dayjs"; +import dayjs, { Dayjs } from "dayjs"; +import { API_URL } from "../../../config"; +import { useAuthHeader } from "react-auth-kit"; + +export interface MeterType { + id: number; + brand: string; + series: string | null; + model: string; + size: number; + description: string; + in_use: boolean; +} + +export interface PartType { + id: number; + name: string; + description: string; +} + +export interface Part { + id: number; + part_number: string; + description: string; + vendor: string | null; + count: number; + note: string; + in_use: boolean; + commonly_used: boolean; + price: number | null; + part_type_id: number; + part_type: PartType; + meter_types: MeterType[]; +} const schema = yup.object().shape({ - from: yup.mixed().nullable().required("From date is required"), - to: yup.mixed().nullable().required("To date is required"), - parts: yup.string().required("At least one Part is required"), + from: yup.mixed().nullable().required("From date is required"), + 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)); + }), + parts: yup + .array() + .of(yup.number().required()) + .min(1, "At least one Part is required"), }); const defaultSchema = { from: dayjs(), to: dayjs(), - parts: "", + parts: [], }; export const InventoryReportView = () => { - const partsQuery = useQuery({ - queryKey: ["Inventory", "report", "parts"], - queryFn: async () => {}, - }); - const { control, reset } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, }); + const authHeader = useAuthHeader(); + const partsQuery = useQuery({ + queryKey: ["Inventory", "report", "parts"], + queryFn: async () => { + const response = await fetch(`${API_URL}/parts`, { + headers: { Authorization: authHeader() }, + }); + if (!response.ok) { + throw new Error("Failed to fetch parts"); + } + return response.json(); + }, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours + }); + return ( { /> - { - if (partsQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} + render={({ field }) => ( + + options.filter((opt) => + `${opt.part_number} ${opt.description}` + .toLowerCase() + .includes(state.inputValue.toLowerCase()), + ) + } + options={ + partsQuery?.data?.filter( + (opt: Part) => opt && opt.id != null, + ) ?? [] + } + getOptionLabel={(option: Part) => + typeof option === "string" + ? option + : `${option.part_number} ${option.description}` + } + isOptionEqualToValue={(a: Part, b: Part) => a?.id === b?.id} + value={field.value ?? null} + onChange={(_, value) => field.onChange(value?.id ?? null)} + loading={partsQuery.isLoading} + renderInput={(params) => ( + + )} + /> + )} /> From 1f12e5420762b8248a434d1d4aeb1ec6857b3a02 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 21 May 2025 21:01:13 -0500 Subject: [PATCH 016/146] [Reports/Inventory] Update Parts to be a mulitselect --- .../RHControlled/ControlledDatepicker.tsx | 3 +- .../src/views/Reports/Inventory/index.tsx | 80 +++++++++++-------- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledDatepicker.tsx b/frontend/src/components/RHControlled/ControlledDatepicker.tsx index b52e84b6..78055d85 100644 --- a/frontend/src/components/RHControlled/ControlledDatepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledDatepicker.tsx @@ -4,6 +4,7 @@ import { Controller } from "react-hook-form"; export default function ControlledDatepicker({ name, control, + size = "small", ...childProps }: any) { return ( @@ -13,7 +14,7 @@ export default function ControlledDatepicker({ render={({ field }) => ( )} diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx index 54a7a78d..0c1c7bd9 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -142,6 +142,7 @@ export const InventoryReportView = () => { label="From" sx={{ minWidth: "15rem" }} control={control} + size="medium" name="from" views={["year", "month"]} openTo="year" @@ -153,6 +154,7 @@ export const InventoryReportView = () => { label="To" sx={{ minWidth: "15rem" }} control={control} + size="medium" name="to" views={["year", "month"]} openTo="year" @@ -163,41 +165,49 @@ export const InventoryReportView = () => { ( - - options.filter((opt) => - `${opt.part_number} ${opt.description}` - .toLowerCase() - .includes(state.inputValue.toLowerCase()), - ) - } - options={ - partsQuery?.data?.filter( - (opt: Part) => opt && opt.id != null, - ) ?? [] - } - getOptionLabel={(option: Part) => - typeof option === "string" - ? option - : `${option.part_number} ${option.description}` - } - isOptionEqualToValue={(a: Part, b: Part) => a?.id === b?.id} - value={field.value ?? null} - onChange={(_, value) => field.onChange(value?.id ?? null)} - loading={partsQuery.isLoading} - renderInput={(params) => ( - - )} - /> - )} + render={({ field }) => { + // Convert stored IDs to Part objects for the `value` prop + const selectedParts = (partsQuery?.data ?? []).filter( + (part) => field?.value?.includes(part.id), + ); + + return ( + opt && opt.id != null, + ) ?? [] + } + getOptionLabel={(option: Part) => + `${option.part_number} ${option.description}` + } + isOptionEqualToValue={(a: Part, b: Part) => a.id === b.id} + value={selectedParts} + onChange={(_, selectedOptions) => + field.onChange(selectedOptions.map((p) => p.id)) + } + filterOptions={(options: Part[], state: any) => + options.filter((opt) => + `${opt.part_number} ${opt.description}` + .toLowerCase() + .includes(state.inputValue.toLowerCase()), + ) + } + loading={partsQuery.isLoading} + renderInput={(params) => ( + + )} + /> + ); + }} /> From 25c91983095cd1d96ab690c30530dc7815c8b108 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 21 May 2025 21:41:39 -0500 Subject: [PATCH 017/146] [App] Update app's layout to keep the topbar on top and sidenav on the side --- frontend/src/App.tsx | 67 ++++++++++++++++++++++++++---- frontend/src/components/Topbar.tsx | 1 - 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 599280cc..8d12e1ae 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import "./App.css"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { AuthProvider, useAuthUser } from "react-auth-kit"; import { Route, @@ -66,19 +66,68 @@ function AppLayout({ } }, [authUser()]); + const topbarRef = useRef(null); + const sidenavRef = useRef(null); + + const [topbarHeight, setTopbarHeight] = useState(0); + const [sidenavWidth, setSidenavWidth] = useState(0); + + // Resize observer for topbar height + useEffect(() => { + if (!topbarRef.current) return; + + const observer = new ResizeObserver(() => { + setTopbarHeight(topbarRef.current!.offsetHeight); + }); + + observer.observe(topbarRef.current); + + return () => observer.disconnect(); + }, []); + + // Resize observer for sidenav width + useEffect(() => { + if (!sidenavRef.current) return; + + const observer = new ResizeObserver(() => { + setSidenavWidth(sidenavRef.current!.offsetWidth); + }); + + observer.observe(sidenavRef.current); + + return () => observer.disconnect(); + }, []); + if (isLoggedIn && hasScopes) return ( - - + + - - + + + @@ -86,15 +135,15 @@ function AppLayout({ {pageComponent} - + ); return null; } diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 9aa6ce5f..1c94ef6c 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -84,7 +84,6 @@ const styles = { justifyContent: "space-between", backgroundColor: "white", py: 1, - boxShadow: "3px 2px 5px -2px rgba(0,0,0,0.2)", }, button: { marginTop: "auto", From 5c4ea09464c9c3ab3f43a1a7a8a4ce2363b394b2 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 21 May 2025 21:55:45 -0500 Subject: [PATCH 018/146] [reports/inventory] Add init data grid table to report page --- .../src/views/Reports/Inventory/index.tsx | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx index 0c1c7bd9..146ff32b 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -10,6 +10,7 @@ import { IconButton, TextField, Tooltip, + Typography, } from "@mui/material"; import { Link } from "react-router-dom"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; @@ -20,6 +21,8 @@ import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; import { API_URL } from "../../../config"; import { useAuthHeader } from "react-auth-kit"; +import { useEffect, useState } from "react"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; export interface MeterType { id: number; @@ -75,7 +78,7 @@ const defaultSchema = { }; export const InventoryReportView = () => { - const { control, reset } = useForm({ + const { control, reset, watch } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, }); @@ -96,6 +99,64 @@ export const InventoryReportView = () => { cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours }); + const [quantities, setQuantities] = useState>({}); + const selectedParts = (partsQuery?.data ?? []).filter((part) => + watch("parts")?.includes(part.id), + ); + + const rows = selectedParts.map((part) => { + const quantity = quantities[part.id] ?? 0; + const unitPrice = part.price ?? 0; + + return { + id: part.id, + part_number: part.part_number, + description: part.description, + price: unitPrice, + quantity, + total: quantity * unitPrice, + }; + }); + + useEffect(() => { + const newQuantities: Record = {}; + for (const part of selectedParts) { + if (!(part.id in quantities)) { + newQuantities[part.id] = Math.floor(Math.random() * 100) + 1; + } + } + setQuantities((prev) => ({ ...prev, ...newQuantities })); + }, [selectedParts]); + + const columns: GridColDef[] = [ + { field: "part_number", headerName: "Part", flex: 1 }, + { field: "description", headerName: "Description", flex: 2 }, + { + field: "price", + headerName: "Cost per unit", + flex: 1, + valueFormatter: (params: any) => + typeof params?.value === "number" + ? `$${params?.value?.toFixed(2)}` + : "$0.00", + }, + { + field: "quantity", + headerName: "Number of units", + flex: 1, + type: "number", + }, + { + field: "total", + headerName: "Total cost", + flex: 1, + valueFormatter: (params: any) => + typeof params?.value === "number" + ? `$${params?.value?.toFixed(2)}` + : "$0.00", + }, + ]; + return ( { /> - + + + + + Total: ${rows.reduce((acc, row) => acc + row.total, 0).toFixed(2)} + + From 84fa3d41bfb6fc4e65ef527cb22d281bb3222acb Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 21 May 2025 22:15:14 -0500 Subject: [PATCH 019/146] [reports/inventory] Fix the datagrid --- .../src/views/Reports/Inventory/index.tsx | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx index 146ff32b..601ab640 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -10,7 +10,6 @@ import { IconButton, TextField, Tooltip, - Typography, } from "@mui/material"; import { Link } from "react-router-dom"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; @@ -21,7 +20,6 @@ import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; import { API_URL } from "../../../config"; import { useAuthHeader } from "react-auth-kit"; -import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; export interface MeterType { @@ -99,14 +97,20 @@ export const InventoryReportView = () => { cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours }); - const [quantities, setQuantities] = useState>({}); const selectedParts = (partsQuery?.data ?? []).filter((part) => watch("parts")?.includes(part.id), ); + let runningTotal = 0; + const rows = selectedParts.map((part) => { - const quantity = quantities[part.id] ?? 0; const unitPrice = part.price ?? 0; + const quantity = part.count ?? 0; + const total = quantity * unitPrice; + + runningTotal += total; + + console.log({ runningTotal }); return { id: part.id, @@ -114,19 +118,12 @@ export const InventoryReportView = () => { description: part.description, price: unitPrice, quantity, - total: quantity * unitPrice, + total, + running_total: runningTotal, }; }); - useEffect(() => { - const newQuantities: Record = {}; - for (const part of selectedParts) { - if (!(part.id in quantities)) { - newQuantities[part.id] = Math.floor(Math.random() * 100) + 1; - } - } - setQuantities((prev) => ({ ...prev, ...newQuantities })); - }, [selectedParts]); + console.log({ selectedParts, rows }); const columns: GridColDef[] = [ { field: "part_number", headerName: "Part", flex: 1 }, @@ -135,10 +132,8 @@ export const InventoryReportView = () => { field: "price", headerName: "Cost per unit", flex: 1, - valueFormatter: (params: any) => - typeof params?.value === "number" - ? `$${params?.value?.toFixed(2)}` - : "$0.00", + valueFormatter: (param: number) => + typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", }, { field: "quantity", @@ -150,10 +145,15 @@ export const InventoryReportView = () => { field: "total", headerName: "Total cost", flex: 1, - valueFormatter: (params: any) => - typeof params?.value === "number" - ? `$${params?.value?.toFixed(2)}` - : "$0.00", + valueFormatter: (param: number) => + typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + }, + { + field: "running_total", + headerName: "Running Total", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param.toFixed(2)}` : "$0.00", }, ]; @@ -285,10 +285,6 @@ export const InventoryReportView = () => { }, }} /> - - - Total: ${rows.reduce((acc, row) => acc + row.total, 0).toFixed(2)} - From ee93b4b590583b6557b04dbd64507206ba2096dd Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 21 May 2025 22:20:05 -0500 Subject: [PATCH 020/146] [reports/inventory] Update layout --- frontend/src/views/Reports/Inventory/index.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/Inventory/index.tsx index 601ab640..e2f39d56 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/Inventory/index.tsx @@ -172,7 +172,12 @@ export const InventoryReportView = () => { sx={{ mb: 0, pb: 0 }} /> - + @@ -195,8 +200,7 @@ export const InventoryReportView = () => { justifyContent="flex-start" alignContent="center" gap={2} - paddingTop={2} - paddingBottom={2} + padding={2} > { /> - + { }} /> - + From dc064842a34e8562da68498d0f2a27f907d8f3ff Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 23 May 2025 08:35:09 -0500 Subject: [PATCH 021/146] [/views] General UI patches --- frontend/src/Home.tsx | 8 +++++++- frontend/src/components/MeterRegisterSelect.tsx | 5 +---- .../src/views/Activities/ActivitiesView.tsx | 17 ++++++++--------- frontend/src/views/Chlorides/ChloridesView.tsx | 8 +++++++- frontend/src/views/Meters/MetersView.tsx | 2 +- .../MonitoringWells/MonitoringWellsView.tsx | 8 +++++++- .../src/views/Parts/MeterTypeDetailsCard.tsx | 2 +- frontend/src/views/Parts/MeterTypesTable.tsx | 8 ++++++-- frontend/src/views/Parts/PartDetailsCard.tsx | 5 +++-- frontend/src/views/Parts/PartsTable.tsx | 2 +- frontend/src/views/Parts/PartsView.tsx | 2 +- frontend/src/views/Reports/Board/index.tsx | 8 +++++++- frontend/src/views/Reports/Chlorides/index.tsx | 8 +++++++- frontend/src/views/Reports/Inventory/index.tsx | 14 ++++++++------ .../src/views/Reports/MonitoringWells/index.tsx | 8 +++++++- frontend/src/views/Reports/Repairs/index.tsx | 8 +++++++- frontend/src/views/Reports/WorkOrders/index.tsx | 8 +++++++- frontend/src/views/Reports/index.tsx | 10 ++++++++-- .../views/UserManagement/PermissionsTable.tsx | 2 +- .../views/UserManagement/RoleDetailsCard.tsx | 2 +- .../src/views/UserManagement/RolesTable.tsx | 2 +- .../views/UserManagement/UserManagementView.tsx | 2 +- .../src/views/UserManagement/UsersTable.tsx | 2 +- .../views/WellManagement/WellManagementView.tsx | 2 +- .../src/views/WorkOrders/WorkOrdersView.tsx | 8 +++++++- 25 files changed, 107 insertions(+), 44 deletions(-) diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index 18773ff9..23a68c7d 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -22,7 +22,13 @@ export const Home = () => { return ( { if (meterType) { - console.log(meterType); setFilteredRegisterList( meterRegisterList.data?.filter( (register: MeterRegister) => @@ -57,8 +56,6 @@ export default function MeterRegisterSelect({ //Check if the selected register is in the filtered list, if not, set it to null useEffect(() => { - console.log(selectedRegister); - console.log(filteredRegisterList); if ( selectedRegister && !filteredRegisterList?.some( @@ -100,7 +97,7 @@ export default function MeterRegisterSelect({ )} - {childProps.error && ( + {childProps.error && childProps.helperText && ( {childProps.helperText} )} diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index 003b2671..009ffe6b 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,11 +1,4 @@ -import { - Box, - Grid, - CardContent, - Card, - CardHeader, - Typography, -} from "@mui/material"; +import { Box, Grid, CardContent, Card, CardHeader } from "@mui/material"; import MeterActivityEntry from "./MeterActivityEntry/MeterActivityEntry"; import { Construction } from "@mui/icons-material"; @@ -17,7 +10,13 @@ export const toggleStyle = { export const ActivitiesView = () => { return ( + - + - + - + - + ( {`${type.brand} - ${type.model}`} ))} diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 29f5b39b..6e6ad1e9 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -37,7 +37,7 @@ export default function PartsTable({ field: "part_type", headerName: "Part Type", width: 200, - valueGetter: (params: any) => params.value?.name, + valueGetter: (params: any) => params?.name, }, { field: "count", headerName: "Count" }, { diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index 4aa7885c..3dd1c37e 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -22,7 +22,7 @@ export default function PartsView() { }, [selectedMeterType]); return ( - + { return ( { return ( { runningTotal += total; - console.log({ runningTotal }); - return { id: part.id, part_number: part.part_number, @@ -123,8 +121,6 @@ export const InventoryReportView = () => { }; }); - console.log({ selectedParts, rows }); - const columns: GridColDef[] = [ { field: "part_number", headerName: "Part", flex: 1 }, { field: "description", headerName: "Description", flex: 2 }, @@ -159,13 +155,19 @@ export const InventoryReportView = () => { return ( - Inventory Report + Parts Used Report } diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index b2f81f72..9d2ee06f 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -44,7 +44,13 @@ export const MonitoringWellsReportView = () => { return ( { return ( { return ( { return ( { diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index f3c26dc6..9b0caf79 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -50,7 +50,7 @@ export default function PermissionsTable() { sx={{ mb: 0, pb: 0 }} /> - + diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 17e00b9d..0b4b91d7 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -181,7 +181,7 @@ export default function RoleDetailsCard({ .includes(x.id), ) .map((scope: SecurityScope) => ( - + {scope.scope_string} ))} diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 8623999b..baaaa279 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -76,7 +76,7 @@ export default function RolesTable({ sx={{ mb: 0, pb: 0 }} /> - + diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index ffec1b53..a33af4d9 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -23,7 +23,7 @@ export default function UserManagementView() { }, [selectedRole]); return ( - + - + + Date: Fri, 23 May 2025 13:53:03 -0500 Subject: [PATCH 022/146] [/frontend] Refactor codebase --- frontend/index.html | 31 ++++ frontend/src/App.css | 90 --------- frontend/src/App.tsx | 33 ++-- frontend/src/Home.tsx | 28 +-- frontend/src/components/BackgroundBox.tsx | 21 +++ frontend/src/components/CustomCardHeader.tsx | 61 ++++++ frontend/src/components/NavLink.tsx | 55 ++++-- frontend/src/index.css | 28 --- frontend/src/index.js | 17 -- frontend/src/main.tsx | 11 +- frontend/src/sidenav.css | 16 -- frontend/src/sidenav.tsx | 2 - .../src/views/Activities/ActivitiesView.tsx | 27 +-- .../src/views/Chlorides/ChloridesView.tsx | 31 +--- .../src/views/Meters/MeterDetailsFields.tsx | 47 ++--- .../Meters/MeterHistory/MeterHistory.tsx | 48 +++-- .../Meters/MeterHistory/MeterHistoryTable.tsx | 173 +++++++++--------- .../MeterHistory/SelectedActivityDetails.tsx | 27 +-- .../Meters/MeterHistory/SelectedBlankCard.tsx | 19 +- .../SelectedObservationDetails.tsx | 27 +-- .../Meters/MeterSelection/MeterSelection.tsx | 23 +-- .../MeterSelection/MeterSelectionTable.tsx | 11 +- frontend/src/views/Meters/MetersView.tsx | 17 +- .../MonitoringWells/MonitoringWellsView.tsx | 31 +--- .../src/views/Parts/MeterTypeDetailsCard.tsx | 31 +--- frontend/src/views/Parts/MeterTypesTable.tsx | 23 +-- frontend/src/views/Parts/PartDetailsCard.tsx | 30 +-- frontend/src/views/Parts/PartsTable.tsx | 23 +-- frontend/src/views/Parts/PartsView.tsx | 25 +-- .../views/Reports/MonitoringWells/index.tsx | 16 +- .../{Inventory => PartsUsed}/index.tsx | 30 +-- frontend/src/views/Reports/index.tsx | 47 +++-- .../views/UserManagement/PermissionsTable.tsx | 22 +-- .../views/UserManagement/RoleDetailsCard.tsx | 25 +-- .../src/views/UserManagement/RolesTable.tsx | 23 +-- .../views/UserManagement/UserDetailsCard.tsx | 32 +--- .../UserManagement/UserManagementView.tsx | 27 +-- .../src/views/UserManagement/UsersTable.tsx | 23 +-- .../views/WellManagement/WellDetailsCard.tsx | 52 +++--- .../WellManagement/WellManagementView.tsx | 13 +- .../src/views/WellManagement/WellsTable.tsx | 22 +-- .../src/views/WorkOrders/WorkOrdersView.tsx | 30 +-- 42 files changed, 550 insertions(+), 818 deletions(-) delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/components/BackgroundBox.tsx create mode 100644 frontend/src/components/CustomCardHeader.tsx delete mode 100644 frontend/src/index.css delete mode 100644 frontend/src/index.js delete mode 100644 frontend/src/sidenav.css rename frontend/src/views/Reports/{Inventory => PartsUsed}/index.tsx (93%) diff --git a/frontend/index.html b/frontend/index.html index 6fded169..0204c989 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,6 +40,37 @@ +
diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 84c3a82d..00000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,90 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.container { - display: flex; - margin-top: 10px; -} - -.link { - text-decoration: none; - color: inherit; -} - -.underlined { - text-decoration: underline; -} - -.custom-card-header-small { - display: flex; - flex-direction: row; - align-items: center; - color: white; - background: #292929; - box-shadow: "120px 120px 100px 120px rgba(0,0,0,0.2)"; - border-radius: 5px; - padding: 10px 10px 10px 14px; - margin: 0; - font-weight: 600; - font-size: 1rem; -} - -.custom-card-header { - display: flex; - flex-direction: row; - align-items: center; - color: white; - background: #292929; - box-shadow: "120px 120px 100px 120px rgba(0,0,0,0.2)"; - border-radius: 5px; - padding: 10px 10px 10px 14px; - margin: 0; - font-weight: 500; - font-size: 1.1rem; -} - -.custom-card-header span { - flex: 1; -} - -.custom-card-header svg { - font-size: 1.3rem; - padding-bottom: 0px; - margin-right: 10px; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d12e1ae..98f14bc9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,3 @@ -import "./App.css"; import { useEffect, useRef, useState } from "react"; import { AuthProvider, useAuthUser } from "react-auth-kit"; import { @@ -11,36 +10,34 @@ 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 { Box, Grid } from "@mui/material"; - -import MonitoringWellsView from "./views/MonitoringWells/MonitoringWellsView"; +import { Box } from "@mui/material"; +import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; -import MetersView from "./views/Meters/MetersView"; -import PartsView from "./views/Parts/PartsView"; -import UserManagementView from "./views/UserManagement/UserManagementView"; +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 Sidenav from "./sidenav"; import { Home } from "./Home"; import Topbar from "./components/Topbar"; import Login from "./login"; import { SecurityScope } from "./interfaces"; -import ChloridesView from "./views/Chlorides/ChloridesView"; +import { ChloridesView } from "./views/Chlorides/ChloridesView"; import { ReportsView } from "./views/Reports"; import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; import { RepairsReportView } from "./views/Reports/Repairs"; -import { InventoryReportView } from "./views/Reports/Inventory"; +import { PartsUsedReportView } from "./views/Reports/PartsUsed"; import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; // A wrapper that handles checking that the user is logged in and has any necessary scopes -function AppLayout({ +const AppLayout = ({ pageComponent, requiredScopes = null, setErrorMessage = null, -}: any) { +}: any) => { const authUser = useAuthUser(); const navigate = useNavigate(); @@ -137,7 +134,7 @@ function AppLayout({ flexGrow: 1, height: "100%", overflowY: "auto", - padding: 2, + p: 3, }} > {pageComponent} @@ -146,9 +143,9 @@ function AppLayout({
); return null; -} +}; -export default function App() { +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 @@ -256,10 +253,10 @@ export default function App() { } /> } + pageComponent={} requiredScopes={["read"]} setErrorMessage={setErrorMessage} /> @@ -352,4 +349,4 @@ export default function App() { ); -} +}; diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index 23a68c7d..c67f86e9 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -1,8 +1,10 @@ -import { Box, Card, CardContent, CardHeader, Typography } from "@mui/material"; +import { Box, Card, CardContent, 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"; export const Home = () => { const versionHistory = [ @@ -21,25 +23,9 @@ export const Home = () => { ]; return ( - - - - Meter Manager Home - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + @@ -65,6 +51,6 @@ export const Home = () => { - + ); }; diff --git a/frontend/src/components/BackgroundBox.tsx b/frontend/src/components/BackgroundBox.tsx new file mode 100644 index 00000000..bc3f96a1 --- /dev/null +++ b/frontend/src/components/BackgroundBox.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Box, BoxProps } from "@mui/material"; + +export const BackgroundBox: React.FC = ({ + children, + sx, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/CustomCardHeader.tsx b/frontend/src/components/CustomCardHeader.tsx new file mode 100644 index 00000000..38e24031 --- /dev/null +++ b/frontend/src/components/CustomCardHeader.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { + CardHeader, + CardHeaderProps, + SvgIconProps, + Box, + Typography, +} from "@mui/material"; + +type CustomCardHeaderProps = Omit & { + title?: string; + icon?: React.ComponentType; +}; + +export const CustomCardHeader: React.FC = ({ + title, + icon: Icon = null, + sx, + ...rest +}) => { + return ( + + + {title} + + {Icon && ( + + )} +
+ } + sx={{ + mb: 0, + pb: 0, + ...sx, + }} + {...rest} + /> + ); +}; diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index c9a8b47a..830c2edd 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -1,29 +1,58 @@ -import { Grid, SvgIconProps } from "@mui/material"; +import { Grid, SvgIconProps, Box, Typography } from "@mui/material"; import TableViewIcon from "@mui/icons-material/TableView"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; export const NavLink = ({ + disabled = false, route, label, Icon, }: { + disabled?: boolean; route: string; label: string; Icon?: React.ComponentType; }) => { + const location = useLocation(); + const isActive = location.pathname === route; + + const content = ( + + {Icon ? ( + + ) : ( + + )} + {label} + + ); + return ( - - {Icon ? ( - - ) : ( - - )} -
{label}
- + {disabled ? ( + content + ) : ( + + {content} + + )}
); }; diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 344b966f..00000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,28 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: #EEF2F6; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -.flex-container { - display: flex; -} - -.flex-child { - flex: 1; - border: 2px solid blue -} - -.flex-child:first-child { - margin-right: 20px; - width: 700px -} diff --git a/frontend/src/index.js b/frontend/src/index.js deleted file mode 100644 index 52558d58..00000000 --- a/frontend/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; -import reportWebVitals from './reportWebVitals'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -// reportWebVitals(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202a..a1b5c629 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,9 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/frontend/src/sidenav.css b/frontend/src/sidenav.css deleted file mode 100644 index 8ec24dfd..00000000 --- a/frontend/src/sidenav.css +++ /dev/null @@ -1,16 +0,0 @@ -.navbar-link { - &:hover { - background-color: rgb(240, 240, 255); - } - padding: 5px; - border-radius: 10px; - margin-left: 5px; - text-decoration: none; - color: #555555; - display: flex; - alignItems: center -} - -.navbar-link-active { - background-color: rgb(240, 240, 255); -} diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index d742ddbb..fbfbacd7 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -4,8 +4,6 @@ import { Grid } from "@mui/material"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { WorkOrder } from "./interfaces"; - -import "./sidenav.css"; import { Assessment, Build, diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index 009ffe6b..a5e08da6 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,6 +1,8 @@ -import { Box, Grid, CardContent, Card, CardHeader } from "@mui/material"; +import { Grid, 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"; export const gridBreakpoints = { xs: 12 }; export const toggleStyle = { @@ -9,24 +11,9 @@ export const toggleStyle = { export const ActivitiesView = () => { return ( - - - - Submit an Activity - - } - sx={{ mb: 0, pb: 0 }} - /> + + + @@ -35,6 +22,6 @@ export const ActivitiesView = () => { - + ); }; diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index f0bff05b..7029531b 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -8,7 +8,6 @@ import { Card, CardContent, Typography, - CardHeader, } from "@mui/material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -27,8 +26,10 @@ import { 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"; -export default function ChloridesView() { +export const ChloridesView = () => { const fetchWithAuth = useFetchWithAuth(); const selectedRegionId = useId(); const [regionId, setregionId] = useState(); @@ -178,25 +179,9 @@ export default function ChloridesView() { }; return ( - - - - Chlorides - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + {error && ( @@ -283,6 +268,6 @@ export default function ChloridesView() { /> - + ); -} +}; diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 109e3d56..53f150ac 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -7,7 +7,7 @@ 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 { Button, Grid, Card, CardContent, CardHeader } from "@mui/material"; +import { Button, Grid, Card, CardContent } from "@mui/material"; import { Table, TableBody, @@ -27,10 +27,10 @@ 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"; // This import is missing from the snippet - +import ControlledMeterStatusTypeSelect from "../../components/RHControlled/ControlledMeterStatusTypeSelect"; import { formatLatLong } from "../../conversions"; import ControlledMeterRegisterSelect from "../../components/RHControlled/ControlledMeterRegisterSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), @@ -38,13 +38,13 @@ const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ meter_register: Yup.object().required("Please select a meter register."), }); -export default function MeterDetailsFields({ +export const MeterDetailsFields = ({ selectedMeterID, meterAddMode, }: { selectedMeterID?: number; meterAddMode: boolean; -}) { +}) => { const meterDetails = useGetMeter({ meter_id: selectedMeterID }); const navigate = useNavigate(); const authUser = useAuthUser(); @@ -77,7 +77,6 @@ export default function MeterDetailsFields({ const createMeter = useCreateMeter(onSuccessfulCreate); const onSaveChanges: SubmitHandler = (data) => { - //console.log(data) updateMeter.mutate(data); }; const onAddMeter: SubmitHandler = (data) => { @@ -110,19 +109,7 @@ export default function MeterDetailsFields({ } }, [meterAddMode]); - // Clear meter register when meter type changes - // const prevMeterTypeRef = React.useRef(); - // useEffect(() => { - // console.log('changing meter type') - // const currentMeterType = watch("meter_type"); - // //Only clear if changing from one meter type to another - // if (prevMeterTypeRef.current !== undefined && prevMeterTypeRef.current !== currentMeterType) { - // setValue('meter_register', undefined); - // } - // prevMeterTypeRef.current = currentMeterType; - // }, [watch("meter_type")]); - - function navigateToNewActivity() { + const navigateToNewActivity = () => { navigate({ pathname: "/activities", search: createSearchParams({ @@ -130,25 +117,13 @@ export default function MeterDetailsFields({ serial_number: meterDetails.data?.serial_number ?? "", }).toString(), }); - } + }; return ( - - Add New Meter - - - ) : ( -
- Selected Meter Details - -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -343,4 +318,4 @@ export default function MeterDetailsFields({
); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index a2c5c15f..1b3302f6 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -1,10 +1,9 @@ import { useState, useEffect } from "react"; import { Box, Grid } from "@mui/material"; - -import MeterHistoryTable from "./MeterHistoryTable"; -import SelectedActivityDetails from "./SelectedActivityDetails"; -import SelectedObservationDetails from "./SelectedObservationDetails"; -import SelectedBlankCard from "./SelectedBlankCard"; +import { MeterHistoryTable } from "./MeterHistoryTable"; +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 { @@ -19,11 +18,11 @@ import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -export default function MeterHistory({ +export const MeterHistory = ({ selectedMeterID, }: { - selectedMeterID: number | undefined; -}) { + selectedMeterID?: number; +}) => { const location = useLocation(); const [selectedHistoryItem, setSelectedHistoryItem] = useState(); const meterHistory = useGetMeterHistory({ meter_id: selectedMeterID }); @@ -40,7 +39,6 @@ export default function MeterHistory({ item.history_item.id == activity_id && item.history_type == MeterHistoryType.Activity, ); - //console.log('history item: ', load_history_item) if (load_history_item) { setSelectedHistoryItem(load_history_item); @@ -53,11 +51,10 @@ export default function MeterHistory({ // Remove the hash from the URL so that the user can switch meters without scrolling location.hash = ""; } else { - console.log("element not found"); + console.error("element not found"); } } // Clear the activity_id from the URL so it doesn't interfere later - console.log("clearing query string"); setSearchParams(); } }, [meterHistory.data]); // Run the effect only when meter history changes otherwise there is a race condition @@ -124,11 +121,10 @@ export default function MeterHistory({ return observation_details; } - //Function to determine what type of details card to output - function getDetailsCard(historyItem: MeterHistoryDTO | undefined) { - if (historyItem == undefined) { - return ; - } else if (historyItem.history_type == MeterHistoryType.Activity) { + const getDetailsCard = (historyItem?: MeterHistoryDTO): JSX.Element => { + if (!historyItem) return ; + + if (historyItem.history_type === MeterHistoryType.Activity) { return ( ); - } else { - return ( - - ); } - } + + return ( + + ); + }; return ( @@ -162,4 +158,4 @@ export default function MeterHistory({
); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index a7427e74..6323bb46 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -1,90 +1,83 @@ -import { Card, CardContent, CardHeader } from "@mui/material"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import HistoryIcon from "@mui/icons-material/History"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc); -dayjs.extend(timezone); - -import { MeterHistoryType } from "../../../enums"; -import { MeterHistoryDTO } from "../../../interfaces"; - -export default function MeterHistoryTable({ - onHistoryItemSelection, - selectedMeterHistory, -}: { - onHistoryItemSelection: Function; - selectedMeterHistory: MeterHistoryDTO[] | undefined; -}) { - const handleRowSelect = (rowDetails: any) => { - onHistoryItemSelection(rowDetails.row); - }; - - const columns: GridColDef[] = [ - { - field: "date", - headerName: "Date", - valueGetter: (value) => { - return dayjs.utc(value).tz("America/Denver"); - }, - valueFormatter: (value) => { - return dayjs - .utc(value) - .tz("America/Denver") - .format("MM/DD/YYYY hh:mm A"); - }, - width: 200, - }, - { - field: "history_type", - headerName: "Activity Type", - valueGetter: (value, row) => { - if (row.history_type == MeterHistoryType.Activity) { - return row.history_item.activity_type.name; - } else return value; - }, - width: 200, - }, - { - field: "well", - headerName: "Well", - valueGetter: (value, row) => { - if (value === null) { - return ""; - } else return row.well.ra_number; - }, - width: 100, - }, - { - field: "history_item", - headerName: "Water Users", - valueGetter: (_, row) => { - return row.history_item.water_users; - }, - width: 200, - }, - ]; - - return ( - - - Meter History - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - ); -} +import { Card, CardContent } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import HistoryIcon from "@mui/icons-material/History"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +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"; + +export const MeterHistoryTable = ({ + onHistoryItemSelection, + selectedMeterHistory, +}: { + onHistoryItemSelection: Function; + selectedMeterHistory: MeterHistoryDTO[] | undefined; +}) => { + const handleRowSelect = (rowDetails: any) => { + onHistoryItemSelection(rowDetails.row); + }; + + const columns: GridColDef[] = [ + { + field: "date", + headerName: "Date", + valueGetter: (value) => { + return dayjs.utc(value).tz("America/Denver"); + }, + valueFormatter: (value) => { + return dayjs + .utc(value) + .tz("America/Denver") + .format("MM/DD/YYYY hh:mm A"); + }, + width: 200, + }, + { + field: "history_type", + headerName: "Activity Type", + valueGetter: (value, row) => { + if (row.history_type == MeterHistoryType.Activity) { + return row.history_item.activity_type.name; + } else return value; + }, + width: 200, + }, + { + field: "well", + headerName: "Well", + valueGetter: (value, row) => { + if (value === null) { + return ""; + } else return row.well.ra_number; + }, + width: 100, + }, + { + field: "history_item", + headerName: "Water Users", + valueGetter: (_, row) => { + return row.history_item.water_users; + }, + width: 200, + }, + ]; + + return ( + + + + + + + ); +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx index a1a13337..1471eb3e 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx @@ -1,14 +1,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; -import { - Grid, - Card, - CardContent, - CardHeader, - Stack, - Button, -} from "@mui/material"; +import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { @@ -34,8 +27,9 @@ 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"; -export default function SelectedActivityDetails({ +export const SelectedActivityDetails = ({ selectedActivity, onDeletion, afterSave, @@ -43,7 +37,7 @@ export default function SelectedActivityDetails({ selectedActivity: PatchActivityForm; onDeletion: () => void; //Function to call when the activity is deleted, use to update the history table afterSave: () => void; -}) { +}) => { const { handleSubmit, control, @@ -124,14 +118,9 @@ export default function SelectedActivityDetails({ return ( - - Activity ID: {selectedActivity.activity_id} - - - } - sx={{ mb: 0, pb: 0 }} + @@ -259,4 +248,4 @@ export default function SelectedActivityDetails({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx index ba666944..c7b3f867 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx @@ -1,19 +1,12 @@ -import { Grid, Card, CardContent, CardHeader } from "@mui/material"; +import { Grid, Card, CardContent } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -//A blank card to display when no history item is selected -export default function SelectedBlankCard() { +// A blank card to display when no history item is selected +export const SelectedBlankCard = () => { return ( - - Selected Details - - - } - sx={{ mb: 0, pb: 0 }} - /> + Select a history item to view details @@ -21,4 +14,4 @@ export default function SelectedBlankCard() { ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx index a0a8faf4..d81e1433 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx @@ -2,14 +2,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { enqueueSnackbar } from "notistack"; -import { - Grid, - Card, - CardContent, - CardHeader, - Stack, - Button, -} from "@mui/material"; +import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { @@ -36,8 +29,9 @@ import { useUpdateObservation, useDeleteObservation, } from "../../../service/ApiServiceNew"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -export default function SelectedObservationDetails({ +export const SelectedObservationDetails = ({ selectedObservation, onDeletion, afterSave, @@ -45,7 +39,7 @@ export default function SelectedObservationDetails({ selectedObservation: PatchObservationForm; onDeletion: () => void; afterSave: () => void; -}) { +}) => { const { handleSubmit, control, reset, watch } = useForm( { defaultValues: selectedObservation }, ); @@ -120,14 +114,9 @@ export default function SelectedObservationDetails({ return ( - - Observation ID: {selectedObservation.observation_id} - - - } - sx={{ mb: 0, pb: 0 }} + @@ -244,4 +233,4 @@ export default function SelectedObservationDetails({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index d5ef2586..9b4eed6f 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -1,9 +1,7 @@ import { useState } from "react"; - -import MeterSelectionTable from "./MeterSelectionTable"; +import { MeterSelectionTable } from "./MeterSelectionTable"; import MeterSelectionMap from "./MeterSelectionMap"; import TabPanel from "../../../components/TabPanel"; - import { Tabs, Tab, @@ -11,20 +9,20 @@ import { Grid, Card, CardContent, - CardHeader, ToggleButtonGroup, ToggleButton, } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { MeterStatusNames } from "../../../enums"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -export default function MeterSelection({ +export const MeterSelection = ({ onMeterSelection, setMeterAddMode, }: { onMeterSelection: Function; setMeterAddMode: Function; -}) { +}) => { const [currentTabIndex, setCurrentTabIndex] = useState(0); const [meterSearchQuery, setMeterSearchQuery] = useState(""); const [meterFilterButtons, setMeterFilterButtons] = useState([ @@ -70,14 +68,9 @@ export default function MeterSelection({ return ( - - All Meters - - - } - sx={{ mb: 0, pb: 0 }} + @@ -145,4 +138,4 @@ export default function MeterSelection({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 69186caf..30dec852 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -1,10 +1,8 @@ import { useState, useEffect } from "react"; import { useDebounce } from "use-debounce"; - import { Box, Button } 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, @@ -22,12 +20,12 @@ interface MeterSelectionTableProps { setMeterAddMode: Function; } -export default function MeterSelectionTable({ +export const MeterSelectionTable = ({ onMeterSelection, meterSearchQuery, setMeterAddMode, meterStatusFilter, -}: MeterSelectionTableProps) { +}: MeterSelectionTableProps) => { const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); const [meterListQueryParams, setMeterListQueryParams] = useState({ @@ -108,11 +106,10 @@ export default function MeterSelectionTable({ if (meterList.data) { setGridRowCount(meterList.data.total); } - //setGridRowCount(meterList.data?.total ?? 0) // Update the meter count when new list is recieved from API }, [meterList]); return ( - + ); -} +}; diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index 7678b580..f376b6c8 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -1,15 +1,16 @@ import { useEffect } from "react"; import { useState } from "react"; import { useLocation } from "react-router-dom"; -import MeterSelection from "./MeterSelection/MeterSelection"; -import MeterDetailsFields from "./MeterDetailsFields"; -import MeterHistory from "./MeterHistory/MeterHistory"; +import { MeterSelection } from "./MeterSelection/MeterSelection"; +import { MeterDetailsFields } from "./MeterDetailsFields"; +import { MeterHistory } from "./MeterHistory/MeterHistory"; -import { Grid, Box } from "@mui/material"; +import { Grid } from "@mui/material"; +import { BackgroundBox } from "../../components/BackgroundBox"; // Main view for the Meters page // Can pass state to this view to pre-select a meter and meter history using React Router useLocation -export default function MetersView() { +export const MetersView = () => { const location = useLocation(); const [selectedMeter, setSelectedMeter] = useState(); const [meterAddMode, setMeterAddMode] = useState(false); @@ -32,7 +33,7 @@ export default function MetersView() { }, [selectedMeter]); return ( - + - + ); -} +}; diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 801597da..5d6f3f8d 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -10,7 +10,6 @@ import { Typography, ListSubheader, useTheme, - CardHeader, } from "@mui/material"; import { useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -37,6 +36,8 @@ import dayjs, { Dayjs } from "dayjs"; import { useFetchWithAuth, useFetchST2 } from "../../hooks"; import { getDataStreamId } from "../../utils/DataStreamUtils"; import { MonitorHeart } from "@mui/icons-material"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const separateAndSortWells = ( wells: MonitoredWell[] = [], @@ -58,7 +59,7 @@ const separateAndSortWells = ( return [outsideRecorderWells, regularWells]; }; -export default function MonitoringWellsView() { +export const MonitoringWellsView = () => { const theme = useTheme(); const fetchWithAuth = useFetchWithAuth(); @@ -181,25 +182,9 @@ export default function MonitoringWellsView() { const [outsideRecorderWells, regularWells] = separateAndSortWells(wells); return ( - - - - Monitored Well Values - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + {error && ( @@ -334,6 +319,6 @@ export default function MonitoringWellsView() { /> - + ); -} +}; diff --git a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx index 49a33224..e9eacb20 100644 --- a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx +++ b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx @@ -23,6 +23,7 @@ import { import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; import { MeterTypeLU } from "../../interfaces"; import { ControlledSelectNonObject } from "../../components/RHControlled/ControlledSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const MeterTypeResolverSchema: Yup.ObjectSchema = Yup.object().shape({ brand: Yup.string().required("Please enter a brand."), @@ -35,13 +36,13 @@ const MeterTypeResolverSchema: Yup.ObjectSchema = Yup.object().shape({ .required("Please indicate if part is in use."), }); -export default function MeterTypeDetailsCard({ +export const MeterTypeDetailsCard = ({ selectedMeterType, meterTypeAddMode, }: { - selectedMeterType: MeterTypeLU | undefined; + selectedMeterType?: MeterTypeLU; meterTypeAddMode: boolean; -}) { +}) => { const { handleSubmit, control, @@ -82,27 +83,13 @@ export default function MeterTypeDetailsCard({ }, [meterTypeAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create Meter Type - {" "} - - ) : ( -
- Edit Meter Type - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -181,4 +168,4 @@ export default function MeterTypeDetailsCard({
); -} +}; diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index f3372027..c26764bb 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, Grid, TextField, @@ -16,14 +15,15 @@ import SearchIcon from "@mui/icons-material/Search"; import { MeterTypeLU } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function MeterTypesTable({ +export const MeterTypesTable = ({ setSelectedMeterType, setMeterTypeAddMode, }: { setSelectedMeterType: Function; setMeterTypeAddMode: Function; -}) { +}) => { const meterTypes = useGetMeterTypeList(); const [meterTypeSearchQuery, setMeterTypeSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -70,14 +70,9 @@ export default function MeterTypesTable({ return ( - - All Meter Types - - - } - sx={{ mb: 0, pb: 0 }} + @@ -109,7 +104,7 @@ export default function MeterTypesTable({
setMeterTypeAddMode(true)} > @@ -137,4 +132,4 @@ export default function MeterTypesTable({
); -} +}; diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index 260d733a..cd56e54e 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, FormControl, Grid, @@ -35,6 +34,7 @@ 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"; const PartResolverSchema: Yup.ObjectSchema = Yup.object().shape({ part_number: Yup.string().required("Please enter a part number."), @@ -55,10 +55,10 @@ interface PartDetailsCard { partAddMode: boolean; } -export default function PartDetailsCard({ +export const PartDetailsCard = ({ selectedPartID, partAddMode, -}: PartDetailsCard) { +}: PartDetailsCard) => { const { handleSubmit, control, @@ -122,27 +122,13 @@ export default function PartDetailsCard({ } // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create Part - {" "} - - ) : ( -
- Edit Part - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -298,4 +284,4 @@ export default function PartDetailsCard({
); -} +}; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 6e6ad1e9..fb6db54f 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, Grid, TextField, @@ -16,14 +15,15 @@ import SearchIcon from "@mui/icons-material/Search"; import { Part } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function PartsTable({ +export const PartsTable = ({ setSelectedPartID, setPartAddMode, }: { setSelectedPartID: Function; setPartAddMode: Function; -}) { +}) => { const partsList = useGetParts(); const [partSearchQuery, setPartSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -83,14 +83,9 @@ export default function PartsTable({ return ( - - All Parts - - - } - sx={{ mb: 0, pb: 0 }} + @@ -126,7 +121,7 @@ export default function PartsTable({
setPartAddMode(true)} > @@ -154,4 +149,4 @@ export default function PartsTable({
); -} +}; diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index 3dd1c37e..dd51163e 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -1,12 +1,13 @@ import { useEffect, useState } from "react"; -import PartsTable from "./PartsTable"; -import { Box, Grid } from "@mui/material"; -import PartDetailsCard from "./PartDetailsCard"; -import MeterTypesTable from "./MeterTypesTable"; -import MeterTypeDetailsCard from "./MeterTypeDetailsCard"; +import { PartsTable } from "./PartsTable"; +import { Grid } from "@mui/material"; +import { PartDetailsCard } from "./PartDetailsCard"; +import { MeterTypesTable } from "./MeterTypesTable"; +import { MeterTypeDetailsCard } from "./MeterTypeDetailsCard"; import { MeterTypeLU } from "../../interfaces"; +import { BackgroundBox } from "../../components/BackgroundBox"; -export default function PartsView() { +export const PartsView = () => { const [selectedPartID, setSelectedPartID] = useState(); const [partAddMode, setPartAddMode] = useState(true); const [selectedMeterType, setSelectedMeterType] = useState(); @@ -22,7 +23,7 @@ export default function PartsView() { }, [selectedMeterType]); return ( - + - + - + - + ); -} +}; diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 9d2ee06f..364cbbec 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -1,6 +1,5 @@ import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; import { - Box, Button, Card, CardContent, @@ -18,6 +17,7 @@ import { useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs from "dayjs"; +import { BackgroundBox } from "../../../components/BackgroundBox"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -43,16 +43,8 @@ export const MonitoringWellsReportView = () => { }); return ( - - + + @@ -142,6 +134,6 @@ export const MonitoringWellsReportView = () => {
- + ); }; diff --git a/frontend/src/views/Reports/Inventory/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx similarity index 93% rename from frontend/src/views/Reports/Inventory/index.tsx rename to frontend/src/views/Reports/PartsUsed/index.tsx index b74675c3..6de89a4e 100644 --- a/frontend/src/views/Reports/Inventory/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -1,11 +1,9 @@ import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; import { Autocomplete, - Box, Button, Card, CardContent, - CardHeader, Grid, IconButton, TextField, @@ -21,6 +19,8 @@ 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"; export interface MeterType { id: number; @@ -75,7 +75,7 @@ const defaultSchema = { parts: [], }; -export const InventoryReportView = () => { +export const PartsUsedReportView = () => { const { control, reset, watch } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, @@ -154,25 +154,9 @@ export const InventoryReportView = () => { ]; return ( - - - - Parts Used Report - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + { - + ); }; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 5309c2df..5e4acc97 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -7,50 +7,49 @@ import { Plumbing, Science, } from "@mui/icons-material"; -import { Box, Card, CardContent, CardHeader } from "@mui/material"; +import { Box, Card, CardContent } from "@mui/material"; import { NavLink } from "../../components/NavLink"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; export const ReportsView = () => { return ( - - - - Reports - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + - + - + { - + ); }; diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 9b0caf79..873e2ca6 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -14,8 +14,9 @@ import SearchIcon from "@mui/icons-material/Search"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { SecurityScope } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function PermissionsTable() { +export const PermissionsTable = () => { const securityScopesList = useGetSecurityScopes(); const [permissionSearchQuery, setPermissionSearchQuery] = useState(""); @@ -40,21 +41,16 @@ export default function PermissionsTable() { return ( - - All Permissions - - - } - sx={{ mb: 0, pb: 0 }} + - {" "} +  Search Permissions } @@ -68,7 +64,7 @@ export default function PermissionsTable() { /> + @@ -89,4 +85,4 @@ export default function PermissionsTable() { ); -} +}; diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 0b4b91d7..645d4225 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -32,6 +32,7 @@ import { } from "../../service/ApiServiceNew"; import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; import { SecurityScope, UserRole } from "../../interfaces"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const RoleResolverSchema: Yup.ObjectSchema = Yup.object().shape({ name: Yup.string().required("Please enter a name."), @@ -42,10 +43,10 @@ interface RoleDetailsCardProps { roleAddMode: boolean; } -export default function RoleDetailsCard({ +export const RoleDetailsCard = ({ selectedRole, roleAddMode, -}: RoleDetailsCardProps) { +}: RoleDetailsCardProps) => { const { handleSubmit, control, @@ -111,21 +112,9 @@ export default function RoleDetailsCard({ return ( - - Create Role - {" "} - - ) : ( -
- Edit Role - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -219,4 +208,4 @@ export default function RoleDetailsCard({
); -} +}; diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index baaaa279..f2cf0f8a 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -3,7 +3,6 @@ import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, Card, - CardHeader, CardContent, Chip, Grid, @@ -15,14 +14,15 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import SearchIcon from "@mui/icons-material/Search"; import { UserRole } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function RolesTable({ +export const RolesTable = ({ setSelectedRole, setRoleAddMode, }: { setSelectedRole: Function; setRoleAddMode: Function; -}) { +}) => { const rolesList = useGetRoles(); const [roleSearchQuery, setRoleSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -66,14 +66,9 @@ export default function RolesTable({ return ( - - All Roles - - - } - sx={{ mb: 0, pb: 0 }} + @@ -92,7 +87,7 @@ export default function RolesTable({ /> setRoleAddMode(true)} > @@ -124,4 +119,4 @@ export default function RolesTable({ ); -} +}; diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 38e8a154..6c94b39e 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -8,7 +8,6 @@ import { Button, Card, CardContent, - CardHeader, Grid, Typography, } from "@mui/material"; @@ -34,6 +33,7 @@ import { ControlledSelect, ControlledSelectNonObject, } from "../../components/RHControlled/ControlledSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ username: Yup.string().required("Please enter a username."), @@ -96,10 +96,10 @@ interface UserDetailsCardProps { // If updating a user password, a special endpoint is called // When updating or creating a user, the values are validated, then the submit handler is called // Any validation not in the validation schema must be checked in the submit handler -export default function UserDetailsCard({ +export const UserDetailsCard = ({ selectedUser, userAddMode, -}: UserDetailsCardProps) { +}: UserDetailsCardProps) => { const rolesList = useGetRoles(); // React hook form for user field values @@ -127,7 +127,7 @@ export default function UserDetailsCard({ enqueueSnackbar("Successfully Created New User!", { variant: "success" }); reset(); } - const onErr = (data: any) => console.log("ERR: ", data); + const onErr = (data: any) => console.error("ERR: ", data); const updateUser = useUpdateUser(onSuccessfulUpdate); const createUser = useCreateUser(onSuccessfulCreate); @@ -177,27 +177,13 @@ export default function UserDetailsCard({ }, [userAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create User - {" "} - - ) : ( -
- Edit User - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -302,4 +288,4 @@ export default function UserDetailsCard({
); -} +}; diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index a33af4d9..63c2144f 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -1,13 +1,14 @@ -import { Box, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; -import UsersTable from "./UsersTable"; -import UserDetailsCard from "./UserDetailsCard"; +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 { RolesTable } from "./RolesTable"; +import { RoleDetailsCard } from "./RoleDetailsCard"; +import { PermissionsTable } from "./PermissionsTable"; +import { BackgroundBox } from "../../components/BackgroundBox"; -export default function UserManagementView() { +export const UserManagementView = () => { const [selectedUser, setSelectedUser] = useState(); const [userAddMode, setUserAddMode] = useState(true); const [selectedRole, setSelectedRole] = useState(); @@ -23,7 +24,7 @@ export default function UserManagementView() { }, [selectedRole]); return ( - + - + - + - + - + ); -} +}; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index b8fb8b94..25a1505e 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -3,7 +3,6 @@ import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, Card, - CardHeader, CardContent, Chip, Grid, @@ -16,14 +15,15 @@ import SearchIcon from "@mui/icons-material/Search"; import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function UsersTable({ +export const UsersTable = ({ setSelectedUser, setUserAddMode, }: { setSelectedUser: Function; setUserAddMode: Function; -}) { +}) => { const usersList = useGetUserAdminList(); const [userSearchQuery, setUserSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -86,14 +86,9 @@ export default function UsersTable({ return ( - - All Users - - - } - sx={{ mb: 0, pb: 0 }} + @@ -131,7 +126,7 @@ export default function UsersTable({ setUserAddMode(true)} > @@ -163,4 +158,4 @@ export default function UsersTable({ ); -} +}; diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index 38b3ae5e..5cc9111f 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -5,7 +5,6 @@ import { Button, Card, CardContent, - CardHeader, Checkbox, FormControlLabel, Grid, @@ -42,6 +41,7 @@ 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"; const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ use_type: Yup.object().required("Please select a use type."), @@ -51,19 +51,24 @@ const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ }), }); -export default function WellDetailsCard( - {selectedWell, wellAddMode,}: {selectedWell?: Well; wellAddMode: boolean;}) { - const { +export const WellDetailsCard = ({ + selectedWell, + wellAddMode, +}: { + selectedWell?: Well; + wellAddMode: boolean; +}) => { + const { handleSubmit, control, setValue, reset, watch, formState: { errors }, - } = useForm({ - resolver: yupResolver(WellResolverSchema), - defaultValues: {location: { latitude: 0, longitude: 0 }} - }); + } = useForm({ + resolver: yupResolver(WellResolverSchema), + defaultValues: { location: { latitude: 0, longitude: 0 } }, + }); const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -110,9 +115,7 @@ export default function WellDetailsCard( }, [wellAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; // Modal related functions const [isWellMergeModalOpen, setIsWellMergeModalOpen] = React.useState(false); @@ -121,21 +124,9 @@ export default function WellDetailsCard( return ( - - Create Well - {" "} - - ) : ( -
- Edit Well - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -196,7 +187,12 @@ export default function WellDetailsCard( {setValue("chloride_group_id", e.target.checked ? 1 : null);}} + onChange={(e) => { + setValue( + "chloride_group_id", + e.target.checked ? 1 : null, + ); + }} size="small" /> } @@ -354,4 +350,4 @@ export default function WellDetailsCard(
); -} +}; diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index ec8d2887..87d62c1e 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -1,8 +1,9 @@ -import { Box, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; -import WellsTable from "./WellsTable"; +import { WellsTable } from "./WellsTable"; import { Well } from "../../interfaces"; -import WellDetailsCard from "./WellDetailsCard"; +import { WellDetailsCard } from "./WellDetailsCard"; +import { BackgroundBox } from "../../components/BackgroundBox"; export default function WellManagementView() { const [wellAddMode, setWellAddMode] = useState(true); @@ -13,9 +14,9 @@ export default function WellManagementView() { }, [selectedWell]); return ( - + - + - + ); } diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index 97552266..a79c08da 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -1,8 +1,6 @@ import { useState } from "react"; - import { Card, - CardHeader, CardContent, Grid, TextField, @@ -10,23 +8,22 @@ import { Tabs, Box, } from "@mui/material"; - import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import SearchIcon from "@mui/icons-material/Search"; - import TabPanel from "../../components/TabPanel"; import WellSelectionTable from "./WellSelectionTable"; import WellSelectionMap from "./WellSelectionMap"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; interface WellsTableProps { setSelectedWell: Function; setWellAddMode: Function; } -export default function WellsTable({ +export const WellsTable = ({ setSelectedWell, setWellAddMode, -}: WellsTableProps) { +}: WellsTableProps) => { const [wellSearchQuery, setWellSearchQuery] = useState(""); const [currentTabIndex, setCurrentTabIndex] = useState(0); @@ -35,14 +32,9 @@ export default function WellsTable({ return ( - - All Wells - - - } - sx={{ mb: 0, pb: 0 }} + @@ -87,4 +79,4 @@ export default function WellsTable({ ); -} +}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx index 26bcc836..ba662d53 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersView.tsx @@ -1,33 +1,21 @@ -import { Box, Card, CardContent, CardHeader } from "@mui/material"; +import { Card, CardContent } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; - import WorkOrdersTable from "./WorkOrdersTable"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; export default function WorkOrdersView() { return ( - - - - Work Orders - - - } - sx={{ mb: 0, pb: 0 }} + + + - + ); } From 0ec1065e6378cebbe3ee44d8f969a34846a10f2d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 27 May 2025 09:51:36 -0500 Subject: [PATCH 023/146] [/views] Rm unused imports --- frontend/src/views/Parts/MeterTypeDetailsCard.tsx | 9 +-------- frontend/src/views/UserManagement/PermissionsTable.tsx | 9 +-------- frontend/src/views/UserManagement/RoleDetailsCard.tsx | 1 - 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx index e9eacb20..a4c2d567 100644 --- a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx +++ b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx @@ -1,13 +1,6 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; -import { - Alert, - Button, - Card, - CardContent, - CardHeader, - Grid, -} from "@mui/material"; +import { Alert, Button, Card, CardContent, Grid } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import SaveIcon from "@mui/icons-material/Save"; diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 873e2ca6..319dffa9 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -1,13 +1,6 @@ import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { - Button, - Card, - CardHeader, - CardContent, - Grid, - TextField, -} from "@mui/material"; +import { Button, Card, CardContent, Grid, TextField } from "@mui/material"; import { useGetSecurityScopes } from "../../service/ApiServiceNew"; import AddIcon from "@mui/icons-material/Add"; import SearchIcon from "@mui/icons-material/Search"; diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 645d4225..8fea20dd 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, FormControl, Grid, From d2fc79593d3626b52da47d3aba691ff6a44eeb94 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Jun 2025 12:42:16 -0500 Subject: [PATCH 024/146] [parts] Add /parts/used endpoint --- api/routes/parts.py | 50 ++++++++++++++++++- .../src/views/Reports/PartsUsed/index.tsx | 34 ++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 187aa60c..1bdd5a92 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,18 +1,22 @@ -from fastapi import Depends, APIRouter, HTTPException +from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload from sqlalchemy import select from typing import List +from datetime import datetime +import calendar from api.models.main_models import ( Parts, + PartsUsed, PartAssociation, PartTypeLU, Meters, MeterTypeLU, + MeterActivities, ) from api.schemas import part_schemas from api.session import get_db -from api.route_util import _get, _patch +from api.route_util import _get from api.enums import ScopedUser from sqlalchemy.exc import IntegrityError @@ -29,6 +33,48 @@ def get_parts(db: Session = Depends(get_db)): return db.scalars(select(Parts).options(joinedload(Parts.part_type))).all() +@part_router.get( + "/parts/used", + tags=["Parts"], + dependencies=[Depends(ScopedUser.Read)], +) +def get_parts_used_within_range( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + parts: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + # Parse and normalize start of "from" month + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + + # Determine end of "to" month + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") + + stmt = ( + select(PartsUsed.c.meter_activity_id, PartsUsed.c.part_id) + .select_from(PartsUsed.join(MeterActivities)) + .where( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts) + ) + ) + + rows = db.execute(stmt).all() + return [dict(row._mapping) for row in rows] + + @part_router.get( "/part_types", response_model=List[part_schemas.PartTypeLU], diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index 6de89a4e..5cb5f7f0 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -83,7 +83,7 @@ export const PartsUsedReportView = () => { const authHeader = useAuthHeader(); const partsQuery = useQuery({ - queryKey: ["Inventory", "report", "parts"], + queryKey: ["Inventory", "report", "partslist"], queryFn: async () => { const response = await fetch(`${API_URL}/parts`, { headers: { Authorization: authHeader() }, @@ -97,6 +97,38 @@ export const PartsUsedReportView = () => { cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours }); + const from = watch("from"); + const to = watch("to"); + const selectedPartIds = watch("parts") ?? []; + + const partsUsedQuery = useQuery({ + queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], + queryFn: async () => { + const searchParams = new URLSearchParams({ + from_month: from?.format("YYYY-MM"), + to_month: to?.format("YYYY-MM"), + }); + + selectedPartIds.forEach((id: number) => { + searchParams.append("parts", id.toString()); + }); + + const response = await fetch( + `${API_URL}/parts/used?${searchParams.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch parts used data"); + } + + return response.json(); + }, + enabled: Boolean(from && to && selectedPartIds?.length > 0), + }); + const selectedParts = (partsQuery?.data ?? []).filter((part) => watch("parts")?.includes(part.id), ); From 272c2c719657735ecf72d53573436088be0e8273 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 2 Jun 2025 13:12:00 -0500 Subject: [PATCH 025/146] [parts] Update parts/used endpoint for better data management --- api/routes/parts.py | 63 +++++++++++++++---- .../src/views/Reports/PartsUsed/index.tsx | 20 +----- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 1bdd5a92..0bd1d997 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,6 +1,6 @@ from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select +from sqlalchemy import select, func from typing import List from datetime import datetime import calendar @@ -38,7 +38,7 @@ def get_parts(db: Session = Depends(get_db)): tags=["Parts"], dependencies=[Depends(ScopedUser.Read)], ) -def get_parts_used_within_range( +def get_parts_used_summary( from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), parts: List[int] = Query(...), @@ -57,22 +57,63 @@ def get_parts_used_within_range( to_date = today else: last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) except ValueError: - raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) - stmt = ( - select(PartsUsed.c.meter_activity_id, PartsUsed.c.part_id) - .select_from(PartsUsed.join(MeterActivities)) - .where( + 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 + ) + .filter( MeterActivities.timestamp_start >= from_date, MeterActivities.timestamp_start <= to_date, - PartsUsed.c.part_id.in_(parts) + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + 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)) + .order_by(Parts.part_number) ) - rows = db.execute(stmt).all() - return [dict(row._mapping) for row in rows] + results = [] + for row in query.all(): + price = row.price or 0 + total = price * row.quantity + results.append({ + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": row.quantity, + "total": total, + }) + + return results @part_router.get( diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index 5cb5f7f0..c87c2c5c 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -129,26 +129,12 @@ export const PartsUsedReportView = () => { enabled: Boolean(from && to && selectedPartIds?.length > 0), }); - const selectedParts = (partsQuery?.data ?? []).filter((part) => - watch("parts")?.includes(part.id), - ); - let runningTotal = 0; - const rows = selectedParts.map((part) => { - const unitPrice = part.price ?? 0; - const quantity = part.count ?? 0; - const total = quantity * unitPrice; - - runningTotal += total; - + const rows = partsUsedQuery?.data?.map((part) => { + runningTotal += part.total; return { - id: part.id, - part_number: part.part_number, - description: part.description, - price: unitPrice, - quantity, - total, + ...part, running_total: runningTotal, }; }); From 2175a8f058088aaf8f87518f335afd59c607cb9a Mon Sep 17 00:00:00 2001 From: CC Date: Tue, 3 Jun 2025 13:33:09 -0600 Subject: [PATCH 026/146] Added register details to get part --- api/models/main_models.py | 1 + api/routes/parts.py | 28 +++++++++++++++++++++++++--- api/schemas/part_schemas.py | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/api/models/main_models.py b/api/models/main_models.py index 8b763257..fcd56376 100644 --- a/api/models/main_models.py +++ b/api/models/main_models.py @@ -581,6 +581,7 @@ class meterRegisters(Base): __tablename__ = "meter_registers" brand: Mapped[str] = mapped_column(String, nullable=False) meter_size: Mapped[float] = mapped_column(Float, nullable=False) + part_id: Mapped[int] = mapped_column(Integer, ForeignKey("Parts.id")) ratio: Mapped[str] = mapped_column(String) dial_units_id: Mapped[int] = mapped_column(Integer, ForeignKey("Units.id"), nullable=False) totalizer_units_id: Mapped[int] = mapped_column(Integer, ForeignKey("Units.id"), nullable=False) diff --git a/api/routes/parts.py b/api/routes/parts.py index 187aa60c..a712cd80 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter, HTTPException from sqlalchemy.orm import Session, joinedload from sqlalchemy import select -from typing import List +from typing import List, Union from api.models.main_models import ( Parts, @@ -9,6 +9,7 @@ PartTypeLU, Meters, MeterTypeLU, + meterRegisters ) from api.schemas import part_schemas from api.session import get_db @@ -41,12 +42,12 @@ def get_part_types(db: Session = Depends(get_db)): @part_router.get( "/part", - response_model=part_schemas.Part, + response_model=Union[part_schemas.Part, part_schemas.Register], dependencies=[Depends(ScopedUser.Read)], tags=["Parts"], ) def get_part(part_id: int, db: Session = Depends(get_db)): - return db.scalars( + selected_part = db.scalars( select(Parts) .where(Parts.id == part_id) .options( @@ -55,6 +56,27 @@ def get_part(part_id: int, db: Session = Depends(get_db)): ) ).first() + # 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 + ) + ).first() + + 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 + ) + + return returned_part + @part_router.patch( "/part", diff --git a/api/schemas/part_schemas.py b/api/schemas/part_schemas.py index cbb241b8..1f147c1a 100644 --- a/api/schemas/part_schemas.py +++ b/api/schemas/part_schemas.py @@ -21,6 +21,23 @@ 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 + ratio: str + dial_units_id: int | None = None + totalizer_units_id: int | None = None + number_of_digits: int | None = None + multiplier: float | None = None + + register_settings: register_details + class PartUsed(ORMBase): part_id: int From 883b25757e0cb4a4a65f671f61e299d35e0acd18 Mon Sep 17 00:00:00 2001 From: CC Date: Tue, 3 Jun 2025 13:36:12 -0600 Subject: [PATCH 027/146] Merge in dev?? --- api/routes/parts.py | 95 +++++- frontend/index.html | 31 ++ frontend/src/App.css | 90 ------ frontend/src/App.tsx | 98 ++++-- frontend/src/Home.tsx | 22 +- frontend/src/components/BackgroundBox.tsx | 21 ++ frontend/src/components/CustomCardHeader.tsx | 61 ++++ .../src/components/MeterRegisterSelect.tsx | 5 +- frontend/src/components/NavLink.tsx | 55 +++- .../RHControlled/ControlledDatepicker.tsx | 3 +- frontend/src/components/Topbar.tsx | 1 - frontend/src/index.css | 28 -- frontend/src/index.js | 17 - frontend/src/main.tsx | 11 +- frontend/src/sidenav.css | 16 - frontend/src/sidenav.tsx | 2 - .../src/views/Activities/ActivitiesView.tsx | 28 +- .../src/views/Chlorides/ChloridesView.tsx | 25 +- .../src/views/Meters/MeterDetailsFields.tsx | 47 +-- .../Meters/MeterHistory/MeterHistory.tsx | 48 ++- .../Meters/MeterHistory/MeterHistoryTable.tsx | 173 +++++----- .../MeterHistory/SelectedActivityDetails.tsx | 27 +- .../Meters/MeterHistory/SelectedBlankCard.tsx | 19 +- .../SelectedObservationDetails.tsx | 27 +- .../Meters/MeterSelection/MeterSelection.tsx | 23 +- .../MeterSelection/MeterSelectionTable.tsx | 11 +- frontend/src/views/Meters/MetersView.tsx | 17 +- .../MonitoringWells/MonitoringWellsView.tsx | 25 +- .../src/views/Parts/MeterTypeDetailsCard.tsx | 42 +-- frontend/src/views/Parts/MeterTypesTable.tsx | 31 +- frontend/src/views/Parts/PartDetailsCard.tsx | 35 +- frontend/src/views/Parts/PartsTable.tsx | 25 +- frontend/src/views/Parts/PartsView.tsx | 25 +- frontend/src/views/Reports/Board/index.tsx | 8 +- .../src/views/Reports/Chlorides/index.tsx | 8 +- .../src/views/Reports/Inventory/index.tsx | 141 -------- .../views/Reports/MonitoringWells/index.tsx | 10 +- .../src/views/Reports/PartsUsed/index.tsx | 306 ++++++++++++++++++ frontend/src/views/Reports/Repairs/index.tsx | 8 +- .../src/views/Reports/WorkOrders/index.tsx | 8 +- frontend/src/views/Reports/index.tsx | 43 +-- .../views/UserManagement/PermissionsTable.tsx | 33 +- .../views/UserManagement/RoleDetailsCard.tsx | 28 +- .../src/views/UserManagement/RolesTable.tsx | 25 +- .../views/UserManagement/UserDetailsCard.tsx | 32 +- .../UserManagement/UserManagementView.tsx | 27 +- .../src/views/UserManagement/UsersTable.tsx | 25 +- .../views/WellManagement/WellDetailsCard.tsx | 52 ++- .../WellManagement/WellManagementView.tsx | 13 +- .../src/views/WellManagement/WellsTable.tsx | 22 +- .../src/views/WorkOrders/WorkOrdersView.tsx | 24 +- 51 files changed, 1047 insertions(+), 950 deletions(-) delete mode 100644 frontend/src/App.css create mode 100644 frontend/src/components/BackgroundBox.tsx create mode 100644 frontend/src/components/CustomCardHeader.tsx delete mode 100644 frontend/src/index.css delete mode 100644 frontend/src/index.js delete mode 100644 frontend/src/sidenav.css delete mode 100644 frontend/src/views/Reports/Inventory/index.tsx create mode 100644 frontend/src/views/Reports/PartsUsed/index.tsx diff --git a/api/routes/parts.py b/api/routes/parts.py index a712cd80..30c34310 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,19 +1,25 @@ -from fastapi import Depends, APIRouter, HTTPException +from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload from sqlalchemy import select from typing import List, Union +from sqlalchemy import select, func +from typing import List +from datetime import datetime +import calendar from api.models.main_models import ( Parts, + PartsUsed, PartAssociation, PartTypeLU, Meters, MeterTypeLU, - meterRegisters + meterRegisters, + MeterActivities, ) from api.schemas import part_schemas from api.session import get_db -from api.route_util import _get, _patch +from api.route_util import _get from api.enums import ScopedUser from sqlalchemy.exc import IntegrityError @@ -30,6 +36,89 @@ def get_parts(db: Session = Depends(get_db)): return db.scalars(select(Parts).options(joinedload(Parts.part_type))).all() +@part_router.get( + "/parts/used", + tags=["Parts"], + dependencies=[Depends(ScopedUser.Read)], +) +def get_parts_used_summary( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + parts: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + # Parse and normalize start of "from" month + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + + # Determine end of "to" month + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + 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 + ) + .filter( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + 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)) + .order_by(Parts.part_number) + ) + + results = [] + for row in query.all(): + price = row.price or 0 + total = price * row.quantity + results.append({ + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": row.quantity, + "total": total, + }) + + return results + + @part_router.get( "/part_types", response_model=List[part_schemas.PartTypeLU], diff --git a/frontend/index.html b/frontend/index.html index 6fded169..0204c989 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -40,6 +40,37 @@ +
diff --git a/frontend/src/App.css b/frontend/src/App.css deleted file mode 100644 index 84c3a82d..00000000 --- a/frontend/src/App.css +++ /dev/null @@ -1,90 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.container { - display: flex; - margin-top: 10px; -} - -.link { - text-decoration: none; - color: inherit; -} - -.underlined { - text-decoration: underline; -} - -.custom-card-header-small { - display: flex; - flex-direction: row; - align-items: center; - color: white; - background: #292929; - box-shadow: "120px 120px 100px 120px rgba(0,0,0,0.2)"; - border-radius: 5px; - padding: 10px 10px 10px 14px; - margin: 0; - font-weight: 600; - font-size: 1rem; -} - -.custom-card-header { - display: flex; - flex-direction: row; - align-items: center; - color: white; - background: #292929; - box-shadow: "120px 120px 100px 120px rgba(0,0,0,0.2)"; - border-radius: 5px; - padding: 10px 10px 10px 14px; - margin: 0; - font-weight: 500; - font-size: 1.1rem; -} - -.custom-card-header span { - flex: 1; -} - -.custom-card-header svg { - font-size: 1.3rem; - padding-bottom: 0px; - margin-right: 10px; -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 599280cc..98f14bc9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,4 @@ -import "./App.css"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { AuthProvider, useAuthUser } from "react-auth-kit"; import { Route, @@ -11,36 +10,34 @@ 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 { Box, Grid } from "@mui/material"; - -import MonitoringWellsView from "./views/MonitoringWells/MonitoringWellsView"; +import { Box } from "@mui/material"; +import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; -import MetersView from "./views/Meters/MetersView"; -import PartsView from "./views/Parts/PartsView"; -import UserManagementView from "./views/UserManagement/UserManagementView"; +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 Sidenav from "./sidenav"; import { Home } from "./Home"; import Topbar from "./components/Topbar"; import Login from "./login"; import { SecurityScope } from "./interfaces"; -import ChloridesView from "./views/Chlorides/ChloridesView"; +import { ChloridesView } from "./views/Chlorides/ChloridesView"; import { ReportsView } from "./views/Reports"; import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; import { RepairsReportView } from "./views/Reports/Repairs"; -import { InventoryReportView } from "./views/Reports/Inventory"; +import { PartsUsedReportView } from "./views/Reports/PartsUsed"; import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; // A wrapper that handles checking that the user is logged in and has any necessary scopes -function AppLayout({ +const AppLayout = ({ pageComponent, requiredScopes = null, setErrorMessage = null, -}: any) { +}: any) => { const authUser = useAuthUser(); const navigate = useNavigate(); @@ -66,19 +63,68 @@ function AppLayout({ } }, [authUser()]); + const topbarRef = useRef(null); + const sidenavRef = useRef(null); + + const [topbarHeight, setTopbarHeight] = useState(0); + const [sidenavWidth, setSidenavWidth] = useState(0); + + // Resize observer for topbar height + useEffect(() => { + if (!topbarRef.current) return; + + const observer = new ResizeObserver(() => { + setTopbarHeight(topbarRef.current!.offsetHeight); + }); + + observer.observe(topbarRef.current); + + return () => observer.disconnect(); + }, []); + + // Resize observer for sidenav width + useEffect(() => { + if (!sidenavRef.current) return; + + const observer = new ResizeObserver(() => { + setSidenavWidth(sidenavRef.current!.offsetWidth); + }); + + observer.observe(sidenavRef.current); + + return () => observer.disconnect(); + }, []); + if (isLoggedIn && hasScopes) return ( - - + + - - + + + @@ -86,20 +132,20 @@ function AppLayout({ {pageComponent} - + ); return null; -} +}; -export default function App() { +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 @@ -207,10 +253,10 @@ export default function App() { } /> } + pageComponent={} requiredScopes={["read"]} setErrorMessage={setErrorMessage} /> @@ -303,4 +349,4 @@ export default function App() { ); -} +}; diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index 18773ff9..c67f86e9 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -1,8 +1,10 @@ -import { Box, Card, CardContent, CardHeader, Typography } from "@mui/material"; +import { Box, Card, CardContent, 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"; export const Home = () => { const versionHistory = [ @@ -21,19 +23,9 @@ export const Home = () => { ]; return ( - - - - Meter Manager Home - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + @@ -59,6 +51,6 @@ export const Home = () => { - + ); }; diff --git a/frontend/src/components/BackgroundBox.tsx b/frontend/src/components/BackgroundBox.tsx new file mode 100644 index 00000000..bc3f96a1 --- /dev/null +++ b/frontend/src/components/BackgroundBox.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Box, BoxProps } from "@mui/material"; + +export const BackgroundBox: React.FC = ({ + children, + sx, + ...rest +}) => { + return ( + + {children} + + ); +}; diff --git a/frontend/src/components/CustomCardHeader.tsx b/frontend/src/components/CustomCardHeader.tsx new file mode 100644 index 00000000..38e24031 --- /dev/null +++ b/frontend/src/components/CustomCardHeader.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { + CardHeader, + CardHeaderProps, + SvgIconProps, + Box, + Typography, +} from "@mui/material"; + +type CustomCardHeaderProps = Omit & { + title?: string; + icon?: React.ComponentType; +}; + +export const CustomCardHeader: React.FC = ({ + title, + icon: Icon = null, + sx, + ...rest +}) => { + return ( + + + {title} + + {Icon && ( + + )} + + } + sx={{ + mb: 0, + pb: 0, + ...sx, + }} + {...rest} + /> + ); +}; diff --git a/frontend/src/components/MeterRegisterSelect.tsx b/frontend/src/components/MeterRegisterSelect.tsx index 3df27bc3..08e56657 100644 --- a/frontend/src/components/MeterRegisterSelect.tsx +++ b/frontend/src/components/MeterRegisterSelect.tsx @@ -42,7 +42,6 @@ export default function MeterRegisterSelect({ //Filter the register list based on the meter type useEffect(() => { if (meterType) { - console.log(meterType); setFilteredRegisterList( meterRegisterList.data?.filter( (register: MeterRegister) => @@ -57,8 +56,6 @@ export default function MeterRegisterSelect({ //Check if the selected register is in the filtered list, if not, set it to null useEffect(() => { - console.log(selectedRegister); - console.log(filteredRegisterList); if ( selectedRegister && !filteredRegisterList?.some( @@ -100,7 +97,7 @@ export default function MeterRegisterSelect({ )} - {childProps.error && ( + {childProps.error && childProps.helperText && ( {childProps.helperText} )} diff --git a/frontend/src/components/NavLink.tsx b/frontend/src/components/NavLink.tsx index c9a8b47a..830c2edd 100644 --- a/frontend/src/components/NavLink.tsx +++ b/frontend/src/components/NavLink.tsx @@ -1,29 +1,58 @@ -import { Grid, SvgIconProps } from "@mui/material"; +import { Grid, SvgIconProps, Box, Typography } from "@mui/material"; import TableViewIcon from "@mui/icons-material/TableView"; -import { Link } from "react-router-dom"; +import { Link, useLocation } from "react-router-dom"; export const NavLink = ({ + disabled = false, route, label, Icon, }: { + disabled?: boolean; route: string; label: string; Icon?: React.ComponentType; }) => { + const location = useLocation(); + const isActive = location.pathname === route; + + const content = ( + + {Icon ? ( + + ) : ( + + )} + {label} + + ); + return ( - - {Icon ? ( - - ) : ( - - )} -
{label}
- + {disabled ? ( + content + ) : ( + + {content} + + )}
); }; diff --git a/frontend/src/components/RHControlled/ControlledDatepicker.tsx b/frontend/src/components/RHControlled/ControlledDatepicker.tsx index b52e84b6..78055d85 100644 --- a/frontend/src/components/RHControlled/ControlledDatepicker.tsx +++ b/frontend/src/components/RHControlled/ControlledDatepicker.tsx @@ -4,6 +4,7 @@ import { Controller } from "react-hook-form"; export default function ControlledDatepicker({ name, control, + size = "small", ...childProps }: any) { return ( @@ -13,7 +14,7 @@ export default function ControlledDatepicker({ render={({ field }) => ( )} diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 9aa6ce5f..1c94ef6c 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -84,7 +84,6 @@ const styles = { justifyContent: "space-between", backgroundColor: "white", py: 1, - boxShadow: "3px 2px 5px -2px rgba(0,0,0,0.2)", }, button: { marginTop: "auto", diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 344b966f..00000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,28 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: #EEF2F6; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} - -.flex-container { - display: flex; -} - -.flex-child { - flex: 1; - border: 2px solid blue -} - -.flex-child:first-child { - margin-right: 20px; - width: 700px -} diff --git a/frontend/src/index.js b/frontend/src/index.js deleted file mode 100644 index 52558d58..00000000 --- a/frontend/src/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import App from './App.tsx'; -import reportWebVitals from './reportWebVitals'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -// reportWebVitals(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202a..a1b5c629 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,10 +1,9 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( , -) +); diff --git a/frontend/src/sidenav.css b/frontend/src/sidenav.css deleted file mode 100644 index 8ec24dfd..00000000 --- a/frontend/src/sidenav.css +++ /dev/null @@ -1,16 +0,0 @@ -.navbar-link { - &:hover { - background-color: rgb(240, 240, 255); - } - padding: 5px; - border-radius: 10px; - margin-left: 5px; - text-decoration: none; - color: #555555; - display: flex; - alignItems: center -} - -.navbar-link-active { - background-color: rgb(240, 240, 255); -} diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index d742ddbb..fbfbacd7 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -4,8 +4,6 @@ import { Grid } from "@mui/material"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { WorkOrder } from "./interfaces"; - -import "./sidenav.css"; import { Assessment, Build, diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index 003b2671..a5e08da6 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,13 +1,8 @@ -import { - Box, - Grid, - CardContent, - Card, - CardHeader, - Typography, -} from "@mui/material"; +import { Grid, 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"; export const gridBreakpoints = { xs: 12 }; export const toggleStyle = { @@ -16,18 +11,9 @@ export const toggleStyle = { export const ActivitiesView = () => { return ( - - - - Submit an Activity - - } - sx={{ mb: 0, pb: 0 }} - /> + + + @@ -36,6 +22,6 @@ export const ActivitiesView = () => { - + ); }; diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 62baf7b5..7029531b 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -8,7 +8,6 @@ import { Card, CardContent, Typography, - CardHeader, } from "@mui/material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -27,8 +26,10 @@ import { 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"; -export default function ChloridesView() { +export const ChloridesView = () => { const fetchWithAuth = useFetchWithAuth(); const selectedRegionId = useId(); const [regionId, setregionId] = useState(); @@ -178,19 +179,9 @@ export default function ChloridesView() { }; return ( - - - - Chlorides - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + {error && ( @@ -277,6 +268,6 @@ export default function ChloridesView() { /> - + ); -} +}; diff --git a/frontend/src/views/Meters/MeterDetailsFields.tsx b/frontend/src/views/Meters/MeterDetailsFields.tsx index 109e3d56..53f150ac 100644 --- a/frontend/src/views/Meters/MeterDetailsFields.tsx +++ b/frontend/src/views/Meters/MeterDetailsFields.tsx @@ -7,7 +7,7 @@ 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 { Button, Grid, Card, CardContent, CardHeader } from "@mui/material"; +import { Button, Grid, Card, CardContent } from "@mui/material"; import { Table, TableBody, @@ -27,10 +27,10 @@ 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"; // This import is missing from the snippet - +import ControlledMeterStatusTypeSelect from "../../components/RHControlled/ControlledMeterStatusTypeSelect"; import { formatLatLong } from "../../conversions"; import ControlledMeterRegisterSelect from "../../components/RHControlled/ControlledMeterRegisterSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ serial_number: Yup.string().required("Please enter a serial number."), @@ -38,13 +38,13 @@ const MeterResolverSchema: Yup.ObjectSchema = Yup.object().shape({ meter_register: Yup.object().required("Please select a meter register."), }); -export default function MeterDetailsFields({ +export const MeterDetailsFields = ({ selectedMeterID, meterAddMode, }: { selectedMeterID?: number; meterAddMode: boolean; -}) { +}) => { const meterDetails = useGetMeter({ meter_id: selectedMeterID }); const navigate = useNavigate(); const authUser = useAuthUser(); @@ -77,7 +77,6 @@ export default function MeterDetailsFields({ const createMeter = useCreateMeter(onSuccessfulCreate); const onSaveChanges: SubmitHandler = (data) => { - //console.log(data) updateMeter.mutate(data); }; const onAddMeter: SubmitHandler = (data) => { @@ -110,19 +109,7 @@ export default function MeterDetailsFields({ } }, [meterAddMode]); - // Clear meter register when meter type changes - // const prevMeterTypeRef = React.useRef(); - // useEffect(() => { - // console.log('changing meter type') - // const currentMeterType = watch("meter_type"); - // //Only clear if changing from one meter type to another - // if (prevMeterTypeRef.current !== undefined && prevMeterTypeRef.current !== currentMeterType) { - // setValue('meter_register', undefined); - // } - // prevMeterTypeRef.current = currentMeterType; - // }, [watch("meter_type")]); - - function navigateToNewActivity() { + const navigateToNewActivity = () => { navigate({ pathname: "/activities", search: createSearchParams({ @@ -130,25 +117,13 @@ export default function MeterDetailsFields({ serial_number: meterDetails.data?.serial_number ?? "", }).toString(), }); - } + }; return ( - - Add New Meter - - - ) : ( -
- Selected Meter Details - -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -343,4 +318,4 @@ export default function MeterDetailsFields({
); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index a2c5c15f..1b3302f6 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -1,10 +1,9 @@ import { useState, useEffect } from "react"; import { Box, Grid } from "@mui/material"; - -import MeterHistoryTable from "./MeterHistoryTable"; -import SelectedActivityDetails from "./SelectedActivityDetails"; -import SelectedObservationDetails from "./SelectedObservationDetails"; -import SelectedBlankCard from "./SelectedBlankCard"; +import { MeterHistoryTable } from "./MeterHistoryTable"; +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 { @@ -19,11 +18,11 @@ import timezone from "dayjs/plugin/timezone"; dayjs.extend(utc); dayjs.extend(timezone); -export default function MeterHistory({ +export const MeterHistory = ({ selectedMeterID, }: { - selectedMeterID: number | undefined; -}) { + selectedMeterID?: number; +}) => { const location = useLocation(); const [selectedHistoryItem, setSelectedHistoryItem] = useState(); const meterHistory = useGetMeterHistory({ meter_id: selectedMeterID }); @@ -40,7 +39,6 @@ export default function MeterHistory({ item.history_item.id == activity_id && item.history_type == MeterHistoryType.Activity, ); - //console.log('history item: ', load_history_item) if (load_history_item) { setSelectedHistoryItem(load_history_item); @@ -53,11 +51,10 @@ export default function MeterHistory({ // Remove the hash from the URL so that the user can switch meters without scrolling location.hash = ""; } else { - console.log("element not found"); + console.error("element not found"); } } // Clear the activity_id from the URL so it doesn't interfere later - console.log("clearing query string"); setSearchParams(); } }, [meterHistory.data]); // Run the effect only when meter history changes otherwise there is a race condition @@ -124,11 +121,10 @@ export default function MeterHistory({ return observation_details; } - //Function to determine what type of details card to output - function getDetailsCard(historyItem: MeterHistoryDTO | undefined) { - if (historyItem == undefined) { - return ; - } else if (historyItem.history_type == MeterHistoryType.Activity) { + const getDetailsCard = (historyItem?: MeterHistoryDTO): JSX.Element => { + if (!historyItem) return ; + + if (historyItem.history_type === MeterHistoryType.Activity) { return ( ); - } else { - return ( - - ); } - } + + return ( + + ); + }; return ( @@ -162,4 +158,4 @@ export default function MeterHistory({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx index a7427e74..6323bb46 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistoryTable.tsx @@ -1,90 +1,83 @@ -import { Card, CardContent, CardHeader } from "@mui/material"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import HistoryIcon from "@mui/icons-material/History"; -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc"; -import timezone from "dayjs/plugin/timezone"; -dayjs.extend(utc); -dayjs.extend(timezone); - -import { MeterHistoryType } from "../../../enums"; -import { MeterHistoryDTO } from "../../../interfaces"; - -export default function MeterHistoryTable({ - onHistoryItemSelection, - selectedMeterHistory, -}: { - onHistoryItemSelection: Function; - selectedMeterHistory: MeterHistoryDTO[] | undefined; -}) { - const handleRowSelect = (rowDetails: any) => { - onHistoryItemSelection(rowDetails.row); - }; - - const columns: GridColDef[] = [ - { - field: "date", - headerName: "Date", - valueGetter: (value) => { - return dayjs.utc(value).tz("America/Denver"); - }, - valueFormatter: (value) => { - return dayjs - .utc(value) - .tz("America/Denver") - .format("MM/DD/YYYY hh:mm A"); - }, - width: 200, - }, - { - field: "history_type", - headerName: "Activity Type", - valueGetter: (value, row) => { - if (row.history_type == MeterHistoryType.Activity) { - return row.history_item.activity_type.name; - } else return value; - }, - width: 200, - }, - { - field: "well", - headerName: "Well", - valueGetter: (value, row) => { - if (value === null) { - return ""; - } else return row.well.ra_number; - }, - width: 100, - }, - { - field: "history_item", - headerName: "Water Users", - valueGetter: (_, row) => { - return row.history_item.water_users; - }, - width: 200, - }, - ]; - - return ( - - - Meter History - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - ); -} +import { Card, CardContent } from "@mui/material"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import HistoryIcon from "@mui/icons-material/History"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +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"; + +export const MeterHistoryTable = ({ + onHistoryItemSelection, + selectedMeterHistory, +}: { + onHistoryItemSelection: Function; + selectedMeterHistory: MeterHistoryDTO[] | undefined; +}) => { + const handleRowSelect = (rowDetails: any) => { + onHistoryItemSelection(rowDetails.row); + }; + + const columns: GridColDef[] = [ + { + field: "date", + headerName: "Date", + valueGetter: (value) => { + return dayjs.utc(value).tz("America/Denver"); + }, + valueFormatter: (value) => { + return dayjs + .utc(value) + .tz("America/Denver") + .format("MM/DD/YYYY hh:mm A"); + }, + width: 200, + }, + { + field: "history_type", + headerName: "Activity Type", + valueGetter: (value, row) => { + if (row.history_type == MeterHistoryType.Activity) { + return row.history_item.activity_type.name; + } else return value; + }, + width: 200, + }, + { + field: "well", + headerName: "Well", + valueGetter: (value, row) => { + if (value === null) { + return ""; + } else return row.well.ra_number; + }, + width: 100, + }, + { + field: "history_item", + headerName: "Water Users", + valueGetter: (_, row) => { + return row.history_item.water_users; + }, + width: 200, + }, + ]; + + return ( + + + + + + + ); +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx index a1a13337..1471eb3e 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedActivityDetails.tsx @@ -1,14 +1,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; -import { - Grid, - Card, - CardContent, - CardHeader, - Stack, - Button, -} from "@mui/material"; +import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { @@ -34,8 +27,9 @@ 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"; -export default function SelectedActivityDetails({ +export const SelectedActivityDetails = ({ selectedActivity, onDeletion, afterSave, @@ -43,7 +37,7 @@ export default function SelectedActivityDetails({ selectedActivity: PatchActivityForm; onDeletion: () => void; //Function to call when the activity is deleted, use to update the history table afterSave: () => void; -}) { +}) => { const { handleSubmit, control, @@ -124,14 +118,9 @@ export default function SelectedActivityDetails({ return ( - - Activity ID: {selectedActivity.activity_id} - - - } - sx={{ mb: 0, pb: 0 }} + @@ -259,4 +248,4 @@ export default function SelectedActivityDetails({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx index ba666944..c7b3f867 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedBlankCard.tsx @@ -1,19 +1,12 @@ -import { Grid, Card, CardContent, CardHeader } from "@mui/material"; +import { Grid, Card, CardContent } from "@mui/material"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -//A blank card to display when no history item is selected -export default function SelectedBlankCard() { +// A blank card to display when no history item is selected +export const SelectedBlankCard = () => { return ( - - Selected Details - - - } - sx={{ mb: 0, pb: 0 }} - /> + Select a history item to view details @@ -21,4 +14,4 @@ export default function SelectedBlankCard() { ); -} +}; diff --git a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx index a0a8faf4..d81e1433 100644 --- a/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx +++ b/frontend/src/views/Meters/MeterHistory/SelectedObservationDetails.tsx @@ -2,14 +2,7 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; import { useAuthUser } from "react-auth-kit"; import { enqueueSnackbar } from "notistack"; -import { - Grid, - Card, - CardContent, - CardHeader, - Stack, - Button, -} from "@mui/material"; +import { Grid, Card, CardContent, Stack, Button } from "@mui/material"; import SaveIcon from "@mui/icons-material/Save"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { @@ -36,8 +29,9 @@ import { useUpdateObservation, useDeleteObservation, } from "../../../service/ApiServiceNew"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -export default function SelectedObservationDetails({ +export const SelectedObservationDetails = ({ selectedObservation, onDeletion, afterSave, @@ -45,7 +39,7 @@ export default function SelectedObservationDetails({ selectedObservation: PatchObservationForm; onDeletion: () => void; afterSave: () => void; -}) { +}) => { const { handleSubmit, control, reset, watch } = useForm( { defaultValues: selectedObservation }, ); @@ -120,14 +114,9 @@ export default function SelectedObservationDetails({ return ( - - Observation ID: {selectedObservation.observation_id} - - - } - sx={{ mb: 0, pb: 0 }} + @@ -244,4 +233,4 @@ export default function SelectedObservationDetails({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index d5ef2586..9b4eed6f 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -1,9 +1,7 @@ import { useState } from "react"; - -import MeterSelectionTable from "./MeterSelectionTable"; +import { MeterSelectionTable } from "./MeterSelectionTable"; import MeterSelectionMap from "./MeterSelectionMap"; import TabPanel from "../../../components/TabPanel"; - import { Tabs, Tab, @@ -11,20 +9,20 @@ import { Grid, Card, CardContent, - CardHeader, ToggleButtonGroup, ToggleButton, } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { MeterStatusNames } from "../../../enums"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; -export default function MeterSelection({ +export const MeterSelection = ({ onMeterSelection, setMeterAddMode, }: { onMeterSelection: Function; setMeterAddMode: Function; -}) { +}) => { const [currentTabIndex, setCurrentTabIndex] = useState(0); const [meterSearchQuery, setMeterSearchQuery] = useState(""); const [meterFilterButtons, setMeterFilterButtons] = useState([ @@ -70,14 +68,9 @@ export default function MeterSelection({ return ( - - All Meters - - - } - sx={{ mb: 0, pb: 0 }} + @@ -145,4 +138,4 @@ export default function MeterSelection({ ); -} +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 69186caf..30dec852 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -1,10 +1,8 @@ import { useState, useEffect } from "react"; import { useDebounce } from "use-debounce"; - import { Box, Button } 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, @@ -22,12 +20,12 @@ interface MeterSelectionTableProps { setMeterAddMode: Function; } -export default function MeterSelectionTable({ +export const MeterSelectionTable = ({ onMeterSelection, meterSearchQuery, setMeterAddMode, meterStatusFilter, -}: MeterSelectionTableProps) { +}: MeterSelectionTableProps) => { const [meterSearchQueryDebounced] = useDebounce(meterSearchQuery, 250); const [meterListQueryParams, setMeterListQueryParams] = useState({ @@ -108,11 +106,10 @@ export default function MeterSelectionTable({ if (meterList.data) { setGridRowCount(meterList.data.total); } - //setGridRowCount(meterList.data?.total ?? 0) // Update the meter count when new list is recieved from API }, [meterList]); return ( - + ); -} +}; diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index a9188da2..f376b6c8 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -1,15 +1,16 @@ import { useEffect } from "react"; import { useState } from "react"; import { useLocation } from "react-router-dom"; -import MeterSelection from "./MeterSelection/MeterSelection"; -import MeterDetailsFields from "./MeterDetailsFields"; -import MeterHistory from "./MeterHistory/MeterHistory"; +import { MeterSelection } from "./MeterSelection/MeterSelection"; +import { MeterDetailsFields } from "./MeterDetailsFields"; +import { MeterHistory } from "./MeterHistory/MeterHistory"; -import { Grid, Box } from "@mui/material"; +import { Grid } from "@mui/material"; +import { BackgroundBox } from "../../components/BackgroundBox"; // Main view for the Meters page // Can pass state to this view to pre-select a meter and meter history using React Router useLocation -export default function MetersView() { +export const MetersView = () => { const location = useLocation(); const [selectedMeter, setSelectedMeter] = useState(); const [meterAddMode, setMeterAddMode] = useState(false); @@ -32,7 +33,7 @@ export default function MetersView() { }, [selectedMeter]); return ( - + - + ); -} +}; diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 02418148..5d6f3f8d 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -10,7 +10,6 @@ import { Typography, ListSubheader, useTheme, - CardHeader, } from "@mui/material"; import { useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -37,6 +36,8 @@ import dayjs, { Dayjs } from "dayjs"; import { useFetchWithAuth, useFetchST2 } from "../../hooks"; import { getDataStreamId } from "../../utils/DataStreamUtils"; import { MonitorHeart } from "@mui/icons-material"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const separateAndSortWells = ( wells: MonitoredWell[] = [], @@ -58,7 +59,7 @@ const separateAndSortWells = ( return [outsideRecorderWells, regularWells]; }; -export default function MonitoringWellsView() { +export const MonitoringWellsView = () => { const theme = useTheme(); const fetchWithAuth = useFetchWithAuth(); @@ -181,19 +182,9 @@ export default function MonitoringWellsView() { const [outsideRecorderWells, regularWells] = separateAndSortWells(wells); return ( - - - - Monitored Well Values - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + {error && ( @@ -328,6 +319,6 @@ export default function MonitoringWellsView() { /> - + ); -} +}; diff --git a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx index 4b91cd7f..a4c2d567 100644 --- a/frontend/src/views/Parts/MeterTypeDetailsCard.tsx +++ b/frontend/src/views/Parts/MeterTypeDetailsCard.tsx @@ -1,13 +1,6 @@ import { useEffect } from "react"; import { useForm, SubmitHandler } from "react-hook-form"; -import { - Alert, - Button, - Card, - CardContent, - CardHeader, - Grid, -} from "@mui/material"; +import { Alert, Button, Card, CardContent, Grid } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import SaveIcon from "@mui/icons-material/Save"; @@ -23,6 +16,7 @@ import { import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; import { MeterTypeLU } from "../../interfaces"; import { ControlledSelectNonObject } from "../../components/RHControlled/ControlledSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const MeterTypeResolverSchema: Yup.ObjectSchema = Yup.object().shape({ brand: Yup.string().required("Please enter a brand."), @@ -35,13 +29,13 @@ const MeterTypeResolverSchema: Yup.ObjectSchema = Yup.object().shape({ .required("Please indicate if part is in use."), }); -export default function MeterTypeDetailsCard({ +export const MeterTypeDetailsCard = ({ selectedMeterType, meterTypeAddMode, }: { - selectedMeterType: MeterTypeLU | undefined; + selectedMeterType?: MeterTypeLU; meterTypeAddMode: boolean; -}) { +}) => { const { handleSubmit, control, @@ -82,27 +76,13 @@ export default function MeterTypeDetailsCard({ }, [meterTypeAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create Meter Type - {" "} - - ) : ( -
- Edit Meter Type - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -145,7 +125,7 @@ export default function MeterTypeDetailsCard({ /> - +
); -} +}; diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index 73a70304..c26764bb 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, Grid, TextField, @@ -16,14 +15,15 @@ import SearchIcon from "@mui/icons-material/Search"; import { MeterTypeLU } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function MeterTypesTable({ +export const MeterTypesTable = ({ setSelectedMeterType, setMeterTypeAddMode, }: { setSelectedMeterType: Function; setMeterTypeAddMode: Function; -}) { +}) => { const meterTypes = useGetMeterTypeList(); const [meterTypeSearchQuery, setMeterTypeSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -31,7 +31,11 @@ export default function MeterTypesTable({ const cols: GridColDef[] = [ { field: "brand", headerName: "Brand", width: 200 }, - { field: "series", headerName: "Series", width: 100 }, + { + field: "series", + headerName: "Series", + width: 100, + }, { field: "model", headerName: "Model Number", width: 200 }, { field: "size", headerName: "Size", width: 100 }, { field: "description", headerName: "Description", width: 200 }, @@ -66,17 +70,12 @@ export default function MeterTypesTable({ return ( - - All Meter Types - - - } - sx={{ mb: 0, pb: 0 }} + - + setMeterTypeAddMode(true)} > @@ -133,4 +132,4 @@ export default function MeterTypesTable({ ); -} +}; diff --git a/frontend/src/views/Parts/PartDetailsCard.tsx b/frontend/src/views/Parts/PartDetailsCard.tsx index da371f35..cd56e54e 100644 --- a/frontend/src/views/Parts/PartDetailsCard.tsx +++ b/frontend/src/views/Parts/PartDetailsCard.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, FormControl, Grid, @@ -35,6 +34,7 @@ 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"; const PartResolverSchema: Yup.ObjectSchema = Yup.object().shape({ part_number: Yup.string().required("Please enter a part number."), @@ -55,10 +55,10 @@ interface PartDetailsCard { partAddMode: boolean; } -export default function PartDetailsCard({ +export const PartDetailsCard = ({ selectedPartID, partAddMode, -}: PartDetailsCard) { +}: PartDetailsCard) => { const { handleSubmit, control, @@ -122,27 +122,13 @@ export default function PartDetailsCard({ } // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create Part - {" "} - - ) : ( -
- Edit Part - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -202,14 +188,14 @@ export default function PartDetailsCard({ /> - + - + ( {`${type.brand} - ${type.model}`} ))} @@ -297,4 +284,4 @@ export default function PartDetailsCard({
); -} +}; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index 29f5b39b..fb6db54f 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, Grid, TextField, @@ -16,14 +15,15 @@ import SearchIcon from "@mui/icons-material/Search"; import { Part } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function PartsTable({ +export const PartsTable = ({ setSelectedPartID, setPartAddMode, }: { setSelectedPartID: Function; setPartAddMode: Function; -}) { +}) => { const partsList = useGetParts(); const [partSearchQuery, setPartSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -37,7 +37,7 @@ export default function PartsTable({ field: "part_type", headerName: "Part Type", width: 200, - valueGetter: (params: any) => params.value?.name, + valueGetter: (params: any) => params?.name, }, { field: "count", headerName: "Count" }, { @@ -83,14 +83,9 @@ export default function PartsTable({ return ( - - All Parts - - - } - sx={{ mb: 0, pb: 0 }} + @@ -126,7 +121,7 @@ export default function PartsTable({ setPartAddMode(true)} > @@ -154,4 +149,4 @@ export default function PartsTable({ ); -} +}; diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index 4aa7885c..dd51163e 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -1,12 +1,13 @@ import { useEffect, useState } from "react"; -import PartsTable from "./PartsTable"; -import { Box, Grid } from "@mui/material"; -import PartDetailsCard from "./PartDetailsCard"; -import MeterTypesTable from "./MeterTypesTable"; -import MeterTypeDetailsCard from "./MeterTypeDetailsCard"; +import { PartsTable } from "./PartsTable"; +import { Grid } from "@mui/material"; +import { PartDetailsCard } from "./PartDetailsCard"; +import { MeterTypesTable } from "./MeterTypesTable"; +import { MeterTypeDetailsCard } from "./MeterTypeDetailsCard"; import { MeterTypeLU } from "../../interfaces"; +import { BackgroundBox } from "../../components/BackgroundBox"; -export default function PartsView() { +export const PartsView = () => { const [selectedPartID, setSelectedPartID] = useState(); const [partAddMode, setPartAddMode] = useState(true); const [selectedMeterType, setSelectedMeterType] = useState(); @@ -22,7 +23,7 @@ export default function PartsView() { }, [selectedMeterType]); return ( - + - + - + - + ); -} +}; diff --git a/frontend/src/views/Reports/Board/index.tsx b/frontend/src/views/Reports/Board/index.tsx index f0a941d0..c7932f33 100644 --- a/frontend/src/views/Reports/Board/index.tsx +++ b/frontend/src/views/Reports/Board/index.tsx @@ -34,7 +34,13 @@ export const BoardReportView = () => { return ( { return ( { - const partsQuery = useQuery({ - queryKey: ["Inventory", "report", "parts"], - queryFn: async () => {}, - }); - - const { control, reset } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultSchema, - }); - - return ( - - - - Inventory Report - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - { - if (partsQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} - /> - - - - - - - - - - - - ); -}; diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index b2f81f72..364cbbec 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -1,6 +1,5 @@ import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; import { - Box, Button, Card, CardContent, @@ -18,6 +17,7 @@ import { useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs from "dayjs"; +import { BackgroundBox } from "../../../components/BackgroundBox"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -43,10 +43,8 @@ export const MonitoringWellsReportView = () => { }); return ( - - + + @@ -136,6 +134,6 @@ export const MonitoringWellsReportView = () => { - + ); }; diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx new file mode 100644 index 00000000..c87c2c5c --- /dev/null +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -0,0 +1,306 @@ +import { ArrowBack, Build, PictureAsPdf } from "@mui/icons-material"; +import { + Autocomplete, + Button, + Card, + CardContent, + Grid, + IconButton, + TextField, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; +import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; +import { Controller, useForm } from "react-hook-form"; +import { 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"; + +export interface MeterType { + id: number; + brand: string; + series: string | null; + model: string; + size: number; + description: string; + in_use: boolean; +} + +export interface PartType { + id: number; + name: string; + description: string; +} + +export interface Part { + id: number; + part_number: string; + description: string; + vendor: string | null; + count: number; + note: string; + in_use: boolean; + commonly_used: boolean; + price: number | null; + part_type_id: number; + part_type: PartType; + meter_types: MeterType[]; +} + +const schema = yup.object().shape({ + from: yup.mixed().nullable().required("From date is required"), + 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)); + }), + parts: yup + .array() + .of(yup.number().required()) + .min(1, "At least one Part is required"), +}); + +const defaultSchema = { + from: dayjs(), + to: dayjs(), + parts: [], +}; + +export const PartsUsedReportView = () => { + const { control, reset, watch } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + + const authHeader = useAuthHeader(); + const partsQuery = useQuery({ + queryKey: ["Inventory", "report", "partslist"], + queryFn: async () => { + const response = await fetch(`${API_URL}/parts`, { + headers: { Authorization: authHeader() }, + }); + if (!response.ok) { + throw new Error("Failed to fetch parts"); + } + return response.json(); + }, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours + }); + + const from = watch("from"); + const to = watch("to"); + const selectedPartIds = watch("parts") ?? []; + + const partsUsedQuery = useQuery({ + queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], + queryFn: async () => { + const searchParams = new URLSearchParams({ + from_month: from?.format("YYYY-MM"), + to_month: to?.format("YYYY-MM"), + }); + + selectedPartIds.forEach((id: number) => { + searchParams.append("parts", id.toString()); + }); + + const response = await fetch( + `${API_URL}/parts/used?${searchParams.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch parts used data"); + } + + return response.json(); + }, + enabled: Boolean(from && to && selectedPartIds?.length > 0), + }); + + let runningTotal = 0; + + const rows = partsUsedQuery?.data?.map((part) => { + runningTotal += part.total; + return { + ...part, + running_total: runningTotal, + }; + }); + + const columns: GridColDef[] = [ + { field: "part_number", headerName: "Part", flex: 1 }, + { field: "description", headerName: "Description", flex: 2 }, + { + field: "price", + headerName: "Cost per unit", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + }, + { + field: "quantity", + headerName: "Number of units", + flex: 1, + type: "number", + }, + { + field: "total", + headerName: "Total cost", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param?.toFixed(2)}` : "$0.00", + }, + { + field: "running_total", + headerName: "Running Total", + flex: 1, + valueFormatter: (param: number) => + typeof param === "number" ? `$${param.toFixed(2)}` : "$0.00", + }, + ]; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + // Convert stored IDs to Part objects for the `value` prop + const selectedParts = (partsQuery?.data ?? []).filter( + (part) => field?.value?.includes(part.id), + ); + + return ( + opt && opt.id != null, + ) ?? [] + } + getOptionLabel={(option: Part) => + `${option.part_number} ${option.description}` + } + isOptionEqualToValue={(a: Part, b: Part) => a.id === b.id} + value={selectedParts} + onChange={(_, selectedOptions) => + field.onChange(selectedOptions.map((p) => p.id)) + } + filterOptions={(options: Part[], state: any) => + options.filter((opt) => + `${opt.part_number} ${opt.description}` + .toLowerCase() + .includes(state.inputValue.toLowerCase()), + ) + } + loading={partsQuery.isLoading} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + + + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/Repairs/index.tsx b/frontend/src/views/Reports/Repairs/index.tsx index a690080b..2f32692a 100644 --- a/frontend/src/views/Reports/Repairs/index.tsx +++ b/frontend/src/views/Reports/Repairs/index.tsx @@ -56,7 +56,13 @@ export const RepairsReportView = () => { return ( { return ( { return ( - - - - Reports - - - } - sx={{ mb: 0, pb: 0 }} - /> + + + - + - + { - + ); }; diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index f3c26dc6..319dffa9 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -1,21 +1,15 @@ import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { - Button, - Card, - CardHeader, - CardContent, - Grid, - TextField, -} from "@mui/material"; +import { Button, Card, CardContent, Grid, TextField } from "@mui/material"; import { useGetSecurityScopes } from "../../service/ApiServiceNew"; import AddIcon from "@mui/icons-material/Add"; import SearchIcon from "@mui/icons-material/Search"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { SecurityScope } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function PermissionsTable() { +export const PermissionsTable = () => { const securityScopesList = useGetSecurityScopes(); const [permissionSearchQuery, setPermissionSearchQuery] = useState(""); @@ -40,21 +34,16 @@ export default function PermissionsTable() { return ( - - All Permissions - - - } - sx={{ mb: 0, pb: 0 }} + - + - {" "} +  Search Permissions } @@ -68,7 +57,7 @@ export default function PermissionsTable() { /> + @@ -89,4 +78,4 @@ export default function PermissionsTable() { ); -} +}; diff --git a/frontend/src/views/UserManagement/RoleDetailsCard.tsx b/frontend/src/views/UserManagement/RoleDetailsCard.tsx index 17e00b9d..8fea20dd 100644 --- a/frontend/src/views/UserManagement/RoleDetailsCard.tsx +++ b/frontend/src/views/UserManagement/RoleDetailsCard.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardHeader, Chip, FormControl, Grid, @@ -32,6 +31,7 @@ import { } from "../../service/ApiServiceNew"; import ControlledTextbox from "../../components/RHControlled/ControlledTextbox"; import { SecurityScope, UserRole } from "../../interfaces"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const RoleResolverSchema: Yup.ObjectSchema = Yup.object().shape({ name: Yup.string().required("Please enter a name."), @@ -42,10 +42,10 @@ interface RoleDetailsCardProps { roleAddMode: boolean; } -export default function RoleDetailsCard({ +export const RoleDetailsCard = ({ selectedRole, roleAddMode, -}: RoleDetailsCardProps) { +}: RoleDetailsCardProps) => { const { handleSubmit, control, @@ -111,21 +111,9 @@ export default function RoleDetailsCard({ return ( - - Create Role - {" "} - - ) : ( -
- Edit Role - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -181,7 +169,7 @@ export default function RoleDetailsCard({ .includes(x.id), ) .map((scope: SecurityScope) => ( - + {scope.scope_string} ))} @@ -219,4 +207,4 @@ export default function RoleDetailsCard({
); -} +}; diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index 8623999b..f2cf0f8a 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -3,7 +3,6 @@ import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, Card, - CardHeader, CardContent, Chip, Grid, @@ -15,14 +14,15 @@ import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBullet import SearchIcon from "@mui/icons-material/Search"; import { UserRole } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function RolesTable({ +export const RolesTable = ({ setSelectedRole, setRoleAddMode, }: { setSelectedRole: Function; setRoleAddMode: Function; -}) { +}) => { const rolesList = useGetRoles(); const [roleSearchQuery, setRoleSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -66,17 +66,12 @@ export default function RolesTable({ return ( - - All Roles - - - } - sx={{ mb: 0, pb: 0 }} + - + @@ -92,7 +87,7 @@ export default function RolesTable({ /> setRoleAddMode(true)} > @@ -124,4 +119,4 @@ export default function RolesTable({ ); -} +}; diff --git a/frontend/src/views/UserManagement/UserDetailsCard.tsx b/frontend/src/views/UserManagement/UserDetailsCard.tsx index 38e8a154..6c94b39e 100644 --- a/frontend/src/views/UserManagement/UserDetailsCard.tsx +++ b/frontend/src/views/UserManagement/UserDetailsCard.tsx @@ -8,7 +8,6 @@ import { Button, Card, CardContent, - CardHeader, Grid, Typography, } from "@mui/material"; @@ -34,6 +33,7 @@ import { ControlledSelect, ControlledSelectNonObject, } from "../../components/RHControlled/ControlledSelect"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; const UserResolverSchema: Yup.ObjectSchema = Yup.object().shape({ username: Yup.string().required("Please enter a username."), @@ -96,10 +96,10 @@ interface UserDetailsCardProps { // If updating a user password, a special endpoint is called // When updating or creating a user, the values are validated, then the submit handler is called // Any validation not in the validation schema must be checked in the submit handler -export default function UserDetailsCard({ +export const UserDetailsCard = ({ selectedUser, userAddMode, -}: UserDetailsCardProps) { +}: UserDetailsCardProps) => { const rolesList = useGetRoles(); // React hook form for user field values @@ -127,7 +127,7 @@ export default function UserDetailsCard({ enqueueSnackbar("Successfully Created New User!", { variant: "success" }); reset(); } - const onErr = (data: any) => console.log("ERR: ", data); + const onErr = (data: any) => console.error("ERR: ", data); const updateUser = useUpdateUser(onSuccessfulUpdate); const createUser = useCreateUser(onSuccessfulCreate); @@ -177,27 +177,13 @@ export default function UserDetailsCard({ }, [userAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; return ( - - Create User - {" "} - - ) : ( -
- Edit User - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -302,4 +288,4 @@ export default function UserDetailsCard({
); -} +}; diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index ffec1b53..63c2144f 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -1,13 +1,14 @@ -import { Box, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; -import UsersTable from "./UsersTable"; -import UserDetailsCard from "./UserDetailsCard"; +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 { RolesTable } from "./RolesTable"; +import { RoleDetailsCard } from "./RoleDetailsCard"; +import { PermissionsTable } from "./PermissionsTable"; +import { BackgroundBox } from "../../components/BackgroundBox"; -export default function UserManagementView() { +export const UserManagementView = () => { const [selectedUser, setSelectedUser] = useState(); const [userAddMode, setUserAddMode] = useState(true); const [selectedRole, setSelectedRole] = useState(); @@ -23,7 +24,7 @@ export default function UserManagementView() { }, [selectedRole]); return ( - + - + - + - + - + ); -} +}; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 7c928aac..25a1505e 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -3,7 +3,6 @@ import { DataGrid, GridColDef } from "@mui/x-data-grid"; import { Button, Card, - CardHeader, CardContent, Chip, Grid, @@ -16,14 +15,15 @@ import SearchIcon from "@mui/icons-material/Search"; import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; -export default function UsersTable({ +export const UsersTable = ({ setSelectedUser, setUserAddMode, }: { setSelectedUser: Function; setUserAddMode: Function; -}) { +}) => { const usersList = useGetUserAdminList(); const [userSearchQuery, setUserSearchQuery] = useState(""); const [filteredRows, setFilteredRows] = useState(); @@ -86,17 +86,12 @@ export default function UsersTable({ return ( - - All Users - - - } - sx={{ mb: 0, pb: 0 }} + - + setUserAddMode(true)} > @@ -163,4 +158,4 @@ export default function UsersTable({ ); -} +}; diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index 38b3ae5e..5cc9111f 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -5,7 +5,6 @@ import { Button, Card, CardContent, - CardHeader, Checkbox, FormControlLabel, Grid, @@ -42,6 +41,7 @@ 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"; const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ use_type: Yup.object().required("Please select a use type."), @@ -51,19 +51,24 @@ const WellResolverSchema: Yup.ObjectSchema = Yup.object().shape({ }), }); -export default function WellDetailsCard( - {selectedWell, wellAddMode,}: {selectedWell?: Well; wellAddMode: boolean;}) { - const { +export const WellDetailsCard = ({ + selectedWell, + wellAddMode, +}: { + selectedWell?: Well; + wellAddMode: boolean; +}) => { + const { handleSubmit, control, setValue, reset, watch, formState: { errors }, - } = useForm({ - resolver: yupResolver(WellResolverSchema), - defaultValues: {location: { latitude: 0, longitude: 0 }} - }); + } = useForm({ + resolver: yupResolver(WellResolverSchema), + defaultValues: { location: { latitude: 0, longitude: 0 } }, + }); const authUser = useAuthUser(); const hasAdminScope = authUser() @@ -110,9 +115,7 @@ export default function WellDetailsCard( }, [wellAddMode]); // Determine if form is valid, {errors} in useEffect or formState's isValid don't work - function hasErrors() { - return Object.keys(errors).length > 0; - } + const hasErrors = () => Object.keys(errors).length > 0; // Modal related functions const [isWellMergeModalOpen, setIsWellMergeModalOpen] = React.useState(false); @@ -121,21 +124,9 @@ export default function WellDetailsCard( return ( - - Create Well - {" "} - - ) : ( -
- Edit Well - {" "} -
- ) - } - sx={{ mb: 0, pb: 0 }} + @@ -196,7 +187,12 @@ export default function WellDetailsCard( {setValue("chloride_group_id", e.target.checked ? 1 : null);}} + onChange={(e) => { + setValue( + "chloride_group_id", + e.target.checked ? 1 : null, + ); + }} size="small" /> } @@ -354,4 +350,4 @@ export default function WellDetailsCard(
); -} +}; diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index bd4a6727..87d62c1e 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -1,8 +1,9 @@ -import { Box, Grid } from "@mui/material"; +import { Grid } from "@mui/material"; import { useEffect, useState } from "react"; -import WellsTable from "./WellsTable"; +import { WellsTable } from "./WellsTable"; import { Well } from "../../interfaces"; -import WellDetailsCard from "./WellDetailsCard"; +import { WellDetailsCard } from "./WellDetailsCard"; +import { BackgroundBox } from "../../components/BackgroundBox"; export default function WellManagementView() { const [wellAddMode, setWellAddMode] = useState(true); @@ -13,9 +14,9 @@ export default function WellManagementView() { }, [selectedWell]); return ( - + - + - + ); } diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index 97552266..a79c08da 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -1,8 +1,6 @@ import { useState } from "react"; - import { Card, - CardHeader, CardContent, Grid, TextField, @@ -10,23 +8,22 @@ import { Tabs, Box, } from "@mui/material"; - import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import SearchIcon from "@mui/icons-material/Search"; - import TabPanel from "../../components/TabPanel"; import WellSelectionTable from "./WellSelectionTable"; import WellSelectionMap from "./WellSelectionMap"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; interface WellsTableProps { setSelectedWell: Function; setWellAddMode: Function; } -export default function WellsTable({ +export const WellsTable = ({ setSelectedWell, setWellAddMode, -}: WellsTableProps) { +}: WellsTableProps) => { const [wellSearchQuery, setWellSearchQuery] = useState(""); const [currentTabIndex, setCurrentTabIndex] = useState(0); @@ -35,14 +32,9 @@ export default function WellsTable({ return ( - - All Wells - - - } - sx={{ mb: 0, pb: 0 }} + @@ -87,4 +79,4 @@ export default function WellsTable({ ); -} +}; diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx index b74db399..ba662d53 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersView.tsx @@ -1,27 +1,21 @@ -import { Box, Card, CardContent, CardHeader } from "@mui/material"; +import { Card, CardContent } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; - import WorkOrdersTable from "./WorkOrdersTable"; +import { BackgroundBox } from "../../components/BackgroundBox"; +import { CustomCardHeader } from "../../components/CustomCardHeader"; export default function WorkOrdersView() { return ( - - - - Work Orders - - - } - sx={{ mb: 0, pb: 0 }} + + + - + ); } From 0b206e86b63f15374807f38f929a020e53fdafef Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 4 Jun 2025 08:31:03 -0500 Subject: [PATCH 028/146] [Reports/PartsUsed] Add filter based on part type field --- .../RHControlled/ControlledSelect.tsx | 9 ++- .../src/views/Reports/PartsUsed/index.tsx | 65 +++++++++++++++++-- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledSelect.tsx b/frontend/src/components/RHControlled/ControlledSelect.tsx index c8d7a47f..81294d96 100644 --- a/frontend/src/components/RHControlled/ControlledSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledSelect.tsx @@ -7,14 +7,19 @@ import { } from "@mui/material"; import { Controller } from "react-hook-form"; -export function ControlledSelect({ control, name, ...childProps }: any) { +export function ControlledSelect({ + control, + name, + size = "small", + ...childProps +}: any) { return ( ( { const from = watch("from"); const to = watch("to"); const selectedPartIds = watch("parts") ?? []; + const partType = watch("part_type"); + + const filteredParts = useMemo(() => { + if (!partsQuery.data) return []; + return partType + ? partsQuery.data.filter((p) => p.part_type_id === partType.id) + : partsQuery.data; + }, [partsQuery.data, partType]); + + useEffect(() => { + const currentParts = watch("parts") ?? []; + const validIds = filteredParts.map((p) => p.id); + const stillValid = currentParts.filter((id) => validIds.includes(id)); + + if (currentParts.length !== stillValid.length) { + // Drop invalid part IDs + reset({ ...watch(), parts: stillValid }); + } + }, [partType, filteredParts]); const partsUsedQuery = useQuery({ queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], @@ -230,6 +266,27 @@ export const PartsUsedReportView = () => { format="YYYY MMMM" /> + + ({ + id: option.part_type_id, + type: option.part_type, + })) + .map((item) => [item.id, item]), // key by id + ).values(), + ]} + getOptionLabel={(option: any) => option.type.name} + /> + { opt && opt.id != null, - ) ?? [] - } + options={filteredParts} getOptionLabel={(option: Part) => `${option.part_number} ${option.description}` } @@ -296,7 +349,7 @@ export const PartsUsedReportView = () => { - + From 1c9e6dc97963b71964ab9c9f6fee65dacc6f179a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 4 Jun 2025 08:59:39 -0500 Subject: [PATCH 029/146] [api/routes/parts] Add endpoint for pdf generation --- api/requirements.txt | 1 + api/routes/parts.py | 152 +++++++++++++++++- .../src/views/Reports/PartsUsed/index.tsx | 58 ++++++- 3 files changed, 206 insertions(+), 5 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index a94165bf..1b133947 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -38,3 +38,4 @@ tzdata==2023.3 uvicorn==0.25.0 watchfiles==0.21.0 websockets==12.0 +weasyprint==65.1 diff --git a/api/routes/parts.py b/api/routes/parts.py index 30c34310..8bb99e71 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,11 +1,13 @@ from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select -from typing import List, Union from sqlalchemy import select, func -from typing import List +from typing import List, Union from datetime import datetime import calendar +from fastapi.responses import StreamingResponse +from weasyprint import HTML +from io import BytesIO +from jinja2 import Template from api.models.main_models import ( Parts, @@ -119,6 +121,150 @@ def get_parts_used_summary( return results +@part_router.get( + "/parts/used/pdf", + tags=["Parts"], + dependencies=[Depends(ScopedUser.Read)], +) +def download_parts_used_pdf( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + parts: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + 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 + ) + .filter( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + 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)) + .order_by(Parts.part_number) + ) + + results = [] + running_total = 0.0 + for row in query.all(): + price = row.price or 0 + quantity = row.quantity or 0 + total = price * quantity + running_total += total + results.append({ + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": quantity, + "total": total, + "running_total": running_total, + }) + + html_template = Template(""" + + + + + +

Parts Usage Report

+

+ From: + {{ from_month }}   + To: + {{ to_month }} +

+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
+ + + """) + + html_content = html_template.render( + rows=results, + from_month=from_month, + to_month=to_month + ) + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=parts_used_report.pdf" + }, + ) + + @part_router.get( "/part_types", response_model=List[part_schemas.PartTypeLU], diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index b3d05cd5..f938a398 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -13,7 +13,7 @@ import { import { Link } from "react-router-dom"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; import { Controller, useForm } from "react-hook-form"; -import { useQuery } from "react-query"; +import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; @@ -207,6 +207,54 @@ export const PartsUsedReportView = () => { }, ]; + const downloadPDFMutation = useMutation({ + mutationFn: async ({ + from, + to, + parts, + }: { + from: Dayjs; + to: Dayjs; + parts: number[]; + }) => { + const params = new URLSearchParams({ + from_month: from.format("YYYY-MM"), + to_month: to.format("YYYY-MM"), + }); + + parts.forEach((id) => params.append("parts", id.toString())); + + const response = await fetch( + `${API_URL}/parts/used/pdf?${params.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("PDF generation failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "parts_used_report.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const handleDownloadPDF = () => { + if (!from || !to || selectedPartIds.length === 0) return; + + downloadPDFMutation.mutate({ + from, + to, + parts: selectedPartIds, + }); + }; + return ( @@ -229,7 +277,13 @@ export const PartsUsedReportView = () => { - + From 53db6eaf9cf80d1881184096ca8d160a4c21215b Mon Sep 17 00:00:00 2001 From: CC Date: Fri, 6 Jun 2025 08:13:52 -0600 Subject: [PATCH 030/146] Add weasyprint to dockerfile --- README.md | 1 + api/Dockerfile | 8 ++++++++ api/requirements.txt | 2 ++ frontend/src/Home.tsx | 1 + 4 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 9c13e614..fdd50663 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Format code](https://github.com/NMWDI/WaterManagerDB/actions/workflows/format_code.yml/badge.svg)](https://github.com/NMWDI/WaterManagerDB/actions/workflows/format_code.yml) ## Versions +- V0.2.0 - Added weasyprint for PDF generation, this requires a new Docker image to be built. - V0.1.52 - Deploy chlorides for admin testing - V0.1.51.1 - Increased frontend signout to 300 minutes - V0.1.51 - Improved monitoring well page diff --git a/api/Dockerfile b/api/Dockerfile index cacf5106..db30018a 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -10,6 +10,14 @@ ENV PYTHONUNBUFFERED 1 # COPY ./requirements.txt . +# Install system dependencies required for WeasyPrint +RUN apt-get update && apt-get install -y \ + libpango-1.0-0 \ + libcairo2 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + && apt-get clean + # RUN pip install --no-cache-dir --upgrade -r requirements.txt diff --git a/api/requirements.txt b/api/requirements.txt index 1b133947..e506b2ac 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -16,6 +16,7 @@ greenlet==3.0.2 h11==0.14.0 httptools==0.6.1 idna==3.6 +Jinja2==3.1.6 packaging==23.2 passlib==1.7.4 psycopg==3.1.16 @@ -39,3 +40,4 @@ uvicorn==0.25.0 watchfiles==0.21.0 websockets==12.0 weasyprint==65.1 + diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index c67f86e9..52999fe8 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -8,6 +8,7 @@ import { CustomCardHeader } from "./components/CustomCardHeader"; export const Home = () => { const versionHistory = [ + "V0.2.0 - Parts-used 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", From e17fa17f3444a472e904eeba937019eb298d35fa Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 20 Jun 2025 16:00:48 -0500 Subject: [PATCH 031/146] [repairs -> maintenance] Update name & route --- frontend/src/App.tsx | 6 +- .../src/views/Reports/Maintenance/index.tsx | 224 ++++++++++++++++++ frontend/src/views/Reports/Repairs/index.tsx | 192 --------------- frontend/src/views/Reports/index.tsx | 5 +- 4 files changed, 229 insertions(+), 198 deletions(-) create mode 100644 frontend/src/views/Reports/Maintenance/index.tsx delete mode 100644 frontend/src/views/Reports/Repairs/index.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98f14bc9..9757e930 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,7 +27,7 @@ import { ChloridesView } from "./views/Chlorides/ChloridesView"; import { ReportsView } from "./views/Reports"; import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; import { MonitoringWellsReportView } from "./views/Reports/MonitoringWells"; -import { RepairsReportView } from "./views/Reports/Repairs"; +import { MaintenanceReportView } from "./views/Reports/Maintenance"; import { PartsUsedReportView } from "./views/Reports/PartsUsed"; import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; @@ -243,10 +243,10 @@ export const App = () => { } /> } + pageComponent={} requiredScopes={["read"]} setErrorMessage={setErrorMessage} /> diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx new file mode 100644 index 00000000..99b14612 --- /dev/null +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -0,0 +1,224 @@ +import { useMemo } from "react"; +import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; +import { + Button, + Card, + CardContent, + Chip, + Grid, + IconButton, + TextField, + Tooltip, +} 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 { 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"; + +interface User { + full_name: string; + id: number; +} + +const schema = yup.object().shape({ + from: yup.mixed().nullable().required("From date is required"), + 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)); + }), + techicians: yup + .array() + .of( + yup.object({ + id: yup.number().required(), + full_name: yup.string().required(), + }), + ) + .min(1, "At least one technician is required"), + trss: yup.string().required("At least one Location is required"), +}); + +const defaultSchema = { + from: dayjs(), + to: dayjs(), + techicians: [], + trss: "", +}; + +export const MaintenanceReportView = () => { + const authHeader = useAuthHeader(); + const techiciansQuery = useQuery({ + queryKey: ["Repairs", "report", "techicians"], + queryFn: async () => { + const response = await fetch(`${API_URL}/users`, { + headers: { Authorization: authHeader() }, + }); + if (!response.ok) { + throw new Error("Failed to fetch users"); + } + + return response.json(); + }, + staleTime: 1000 * 60 * 60 * 24, // 24 hours + cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours + }); + + const { control, reset, setValue } = useForm({ + resolver: yupResolver(schema), + defaultValues: defaultSchema, + }); + + const allTechniciansOption = { id: -1, full_name: "All Technicians" }; + + const technicianOptions = useMemo(() => { + const base = techiciansQuery.data ?? []; + return [...base, allTechniciansOption]; + }, [techiciansQuery.data]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + option?.full_name ?? ""} + isOptionEqualToValue={(option: User, value: User) => + option?.id === value?.id + } + onChange={(_, selected) => { + if (selected.some((tech) => tech.id === -1)) { + // Replace selection with all (excluding the synthetic "All Technicians") + setValue("techicians", techiciansQuery.data ?? []); + } else { + setValue("techicians", selected); + } + }} + renderInput={(params) => { + if (techiciansQuery.isLoading) + params.inputProps.value = "Loading..."; + return ( + + ); + }} + renderTags={(selected, getTagProps) => { + const allSelected = + selected.length === techiciansQuery.data?.length && + selected.every((sel) => + techiciansQuery.data?.some((t) => t.id === sel.id), + ); + + if (allSelected) { + return ( + + ); + } + + return selected.map((option, index) => ( + + )); + }} + /> + + + + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/Repairs/index.tsx b/frontend/src/views/Reports/Repairs/index.tsx deleted file mode 100644 index 2f32692a..00000000 --- a/frontend/src/views/Reports/Repairs/index.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; -import { - Box, - Button, - Card, - CardContent, - CardHeader, - Grid, - IconButton, - TextField, - Tooltip, -} 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 { useQuery } from "react-query"; -import * as yup from "yup"; -import { yupResolver } from "@hookform/resolvers/yup"; -import dayjs from "dayjs"; - -const schema = yup.object().shape({ - time: yup.mixed().nullable().required("Date is required"), - techician: yup.string().required("Techician is required"), - meters: yup.string().required("At least one Meter is required"), - locations: yup.string().required("At least one Location is required"), -}); - -const defaultSchema = { - time: dayjs(), - techician: "", - meters: "", - locations: "", -}; - -export const RepairsReportView = () => { - const techiciansQuery = useQuery({ - queryKey: ["Repairs", "report", "techicians"], - queryFn: async () => {}, - }); - - const metersQuery = useQuery({ - queryKey: ["Repairs", "report", "meters"], - queryFn: async () => {}, - }); - - const locationsQuery = useQuery({ - queryKey: ["Repairs", "report", "locations"], - queryFn: async () => {}, - }); - - const { control, reset } = useForm({ - resolver: yupResolver(schema), - defaultValues: defaultSchema, - }); - - return ( - - - - Repairs Report - - - } - sx={{ mb: 0, pb: 0 }} - /> - - - - - - - - - - - - - - - - - - - - - - - - - { - if (techiciansQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} - /> - - - { - if (metersQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} - /> - - - { - if (locationsQuery.isLoading) - params.inputProps.value = "Loading..."; - return ( - - ); - }} - /> - - - - - - - - - - - - ); -}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 5e4acc97..b25c75fe 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -32,9 +32,8 @@ export const ReportsView = () => { Icon={MonitorHeart} /> Date: Tue, 24 Jun 2025 11:03:26 -0500 Subject: [PATCH 032/146] [package] Add @mui/x-charts --- frontend/package-lock.json | 343 ++++++++++++++++++++++++++++++++++--- frontend/package.json | 1 + 2 files changed, 323 insertions(+), 21 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7819933..a6596cea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.10.6", "@mui/material": "^5.15.14", + "@mui/x-charts": "^8.5.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", "dayjs": "^1.11.9", @@ -132,13 +133,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz", - "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } @@ -1495,10 +1493,13 @@ } }, "node_modules/@mui/types": { - "version": "7.2.21", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.21.tgz", - "integrity": "sha512-6HstngiUxNqLU+/DPqlUJDIPbzUBxIVHb1MmXP0eTWDIROiCR2viugXpEif0PPe2mLqqakPzzRClWAnK+8UJww==", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz", + "integrity": "sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==", "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1" + }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -1538,6 +1539,173 @@ } } }, + "node_modules/@mui/x-charts": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.5.3.tgz", + "integrity": "sha512-aLU3KNA5bfKufxCPxBYx34xOn1mY5xaYGxxImEIQhL1BDnsjdkeF7b7gitL62XHpJe7ceU0nr2PbAr8msU0ZBQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "@mui/utils": "^7.1.1", + "@mui/x-charts-vendor": "8.5.3", + "@mui/x-internals": "8.5.3", + "bezier-easing": "^2.1.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@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" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.5.3.tgz", + "integrity": "sha512-H05cb0c2qfRhWLPcwtiIU8BOcKTrMNvhgmRAvJJXpmlirOA1km8dUlR71VeUvJiCthhVIHKyFkPPzFYKgHAfng==", + "license": "MIT AND ISC", + "dependencies": { + "@babel/runtime": "^7.27.6", + "@types/d3-color": "^3.1.3", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.9", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-color": "^3.1.0", + "d3-delaunay": "^6.0.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.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" + } + }, + "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", + "dependencies": { + "d3-path": "^3.1.0" + }, + "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" + }, + "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" + } + }, + "node_modules/@mui/x-charts/node_modules/@mui/utils": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "@mui/types": "^7.4.3", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.0" + }, + "engines": { + "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/@mui/x-charts/node_modules/@mui/x-internals": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.5.3.tgz", + "integrity": "sha512-ImCg4E3DT3XoDIZO0pNCbB7iw14N+YCFY3J1V28POwCD7P2f3HSIz4jwzM006oYxI6bqeE6LMfpdPRDW6s6dQw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "@mui/utils": "^7.1.1", + "reselect": "^5.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "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", @@ -2380,6 +2548,63 @@ "url": "https://opencollective.com/turf" } }, + "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==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "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", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3301,6 +3526,12 @@ "node": ">= 0.6.0" } }, + "node_modules/bezier-easing": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", + "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==", + "license": "MIT" + }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -3996,6 +4227,18 @@ "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", @@ -4078,6 +4321,46 @@ "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", "license": "BSD-3-Clause" }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "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==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale/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" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", @@ -4156,6 +4439,15 @@ "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", @@ -5551,6 +5843,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7201,9 +7502,9 @@ } }, "node_modules/react-is": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", + "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", "license": "MIT" }, "node_modules/react-leaflet": { @@ -7344,12 +7645,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/registry-auth-token": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", @@ -7544,6 +7839,12 @@ "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.34.9", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", @@ -8511,9 +8812,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", - "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "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==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/frontend/package.json b/frontend/package.json index 25dc9cda..5f4b78a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.10.6", "@mui/material": "^5.15.14", + "@mui/x-charts": "^8.5.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", "dayjs": "^1.11.9", From a3c7336f50dca8a6f2a3c6e43c50dd99ab6733a8 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 24 Jun 2025 11:52:40 -0500 Subject: [PATCH 033/146] [Maintenance] Add dummy pie charts --- .../src/views/Reports/Maintenance/index.tsx | 126 ++++++++++++++---- 1 file changed, 98 insertions(+), 28 deletions(-) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 99b14612..7157c734 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -1,14 +1,17 @@ import { useMemo } from "react"; import { ArrowBack, PictureAsPdf, Plumbing } from "@mui/icons-material"; import { + Box, Button, Card, CardContent, Chip, Grid, IconButton, + Stack, TextField, Tooltip, + Typography, } from "@mui/material"; import { Link } from "react-router-dom"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; @@ -23,12 +26,20 @@ 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"; interface User { full_name: string; id: number; } +const ALL_TECHNICIANS_ID = -1; + +const allTechniciansOption: User = { + id: ALL_TECHNICIANS_ID, + full_name: "All Technicians", +}; + const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), to: yup @@ -54,10 +65,15 @@ const schema = yup.object().shape({ const defaultSchema = { from: dayjs(), to: dayjs(), - techicians: [], + techicians: [{ ...allTechniciansOption }], trss: "", }; +const size = { + width: 400, + height: 400, +}; + export const MaintenanceReportView = () => { const authHeader = useAuthHeader(); const techiciansQuery = useQuery({ @@ -81,13 +97,24 @@ export const MaintenanceReportView = () => { defaultValues: defaultSchema, }); - const allTechniciansOption = { id: -1, full_name: "All Technicians" }; - const technicianOptions = useMemo(() => { const base = techiciansQuery.data ?? []; + console.log({ base }); return [...base, allTechniciansOption]; }, [techiciansQuery.data]); + const numberOfRepairsPieChartData = [ + { value: 55, label: "Meter A" }, + { value: 10, label: "Meter B" }, + { value: 15, label: "Meter C" }, + ]; + + const numberOfPMsPieChartData = [ + { value: 5, label: "Meter A" }, + { value: 15, label: "Meter B" }, + { value: 20, label: "Meter C" }, + ]; + return ( @@ -163,17 +190,23 @@ export const MaintenanceReportView = () => { isOptionEqualToValue={(option: User, value: User) => option?.id === value?.id } - onChange={(_, selected) => { - if (selected.some((tech) => tech.id === -1)) { - // Replace selection with all (excluding the synthetic "All Technicians") - setValue("techicians", techiciansQuery.data ?? []); + onChange={(_: React.SyntheticEvent, selected: User[]) => { + const isSelectingAll = selected.some( + (tech) => tech.id === ALL_TECHNICIANS_ID, + ); + const allTechs = techiciansQuery.data ?? []; + + if (isSelectingAll) { + // Set all real users as selected, excluding the synthetic "All Technicians" + setValue("techicians", allTechs); } else { setValue("techicians", selected); } }} - renderInput={(params) => { - if (techiciansQuery.isLoading) + renderInput={(params: Parameters[0]) => { + if (techiciansQuery.isLoading && params.inputProps) { params.inputProps.value = "Loading..."; + } return ( { /> ); }} - renderTags={(selected, getTagProps) => { - const allSelected = - selected.length === techiciansQuery.data?.length && - selected.every((sel) => - techiciansQuery.data?.some((t) => t.id === sel.id), - ); - - if (allSelected) { - return ( - - ); - } - - return selected.map((option, index) => ( + renderTags={(selected: User[], getTagProps: any) => + selected.map((option, index) => ( - )); - }} + )) + } /> + + + + Number of Repairs + + + + + Number of Preventative Maintenances + + + + + From a1a6fc1eedca80f86566d6bc0d2200eb1f7972fa Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 24 Jun 2025 12:01:58 -0500 Subject: [PATCH 034/146] [Maintenance] Add template data grid to report --- .../src/views/Reports/Maintenance/index.tsx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 7157c734..bafa6ce2 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -27,6 +27,7 @@ import ControlledTextbox from "../../../components/RHControlled/ControlledTextbo import { useAuthHeader } from "react-auth-kit"; import { API_URL } from "../../../config"; import { PieChart } from "@mui/x-charts"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; interface User { full_name: string; @@ -115,6 +116,28 @@ export const MaintenanceReportView = () => { { value: 20, label: "Meter C" }, ]; + const columns: GridColDef[] = [ + { field: "date_time", headerName: "Date / Time", flex: 1 }, + { field: "technician", headerName: "Technician", flex: 1 }, + { + field: "number_of_repais", + headerName: "Number of Repairs", + type: "number", + flex: 1, + }, + { + field: "number_of_pms", + headerName: "Number of Preventative Maintenances", + type: "number", + flex: 1, + }, + { + field: "meter", + headerName: "Meter", + flex: 1, + }, + ]; + return ( @@ -281,7 +304,20 @@ export const MaintenanceReportView = () => {
- + + + From 5c55075dcf7d4c4e244c62c919502fcebcdd9c9a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 09:34:29 -0500 Subject: [PATCH 035/146] [maintenance] Init --- api/routes/maintenance.py | 277 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 api/routes/maintenance.py diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py new file mode 100644 index 00000000..f590ee3c --- /dev/null +++ b/api/routes/maintenance.py @@ -0,0 +1,277 @@ +from fastapi import Depends, APIRouter, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import func +from pydantic import BaseModel +from typing import List +from datetime import datetime +import calendar +from fastapi.responses import StreamingResponse +from weasyprint import HTML +from io import BytesIO +from jinja2 import Template + +from api.models.main_models import ( + Parts, + PartsUsed, + PartAssociation, + PartTypeLU, + Meters, + MeterTypeLU, + meterRegisters, + MeterActivities, +) +from api.session import get_db +from api.enums import ScopedUser + +part_router = APIRouter() + +# --- Pydantic response models --- + + +class MeterSummary(BaseModel): + meter: str + count: int + + +class MaintenanceRow(BaseModel): + date_time: datetime + technician: str + number_of_repairs: int + number_of_pms: int + meter: str + + +class MaintenanceSummaryResponse(BaseModel): + repairs_by_meter: List[MeterSummary] + pms_by_meter: List[MeterSummary] + table_rows: List[MaintenanceRow] + + +@part_router.get( + "/maintenance", + tags=["Maintenance"], + response_model=MaintenanceSummaryResponse, + dependencies=[Depends(ScopedUser.Read)], +) +def get_maintenance_summary( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + trss: str = Query(...), + technicians: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + # Parse and normalize start of "from" month + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + + # Determine end of "to" month + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + 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 + ) + .filter( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + 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)) + .order_by(Parts.part_number) + ) + + results = [] + for row in query.all(): + price = row.price or 0 + total = price * row.quantity + results.append({ + "id": row.id, + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": row.quantity, + "total": total, + }) + + return results + + +@part_router.get( + "/maintenance/pdf", + tags=["Maintenance"], + dependencies=[Depends(ScopedUser.Read)], +) +def download_parts_used_pdf( + from_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), + trss: str = Query(...), + technicians: List[int] = Query(...), + db: Session = Depends(get_db), +): + try: + from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) + to_dt = datetime.strptime(to_month, "%Y-%m") + year, month = to_dt.year, to_dt.month + today = datetime.now() + + if year == today.year and month == today.month: + to_date = today + else: + last_day = calendar.monthrange(year, month)[1] + to_date = to_dt.replace( + day=last_day, + hour=23, + minute=59, + second=59 + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="Invalid date format. Use YYYY-MM." + ) + + 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 + ) + .filter( + MeterActivities.timestamp_start >= from_date, + MeterActivities.timestamp_start <= to_date, + PartsUsed.c.part_id.in_(parts), + ) + .group_by(PartsUsed.c.part_id) + .subquery() + ) + + query = ( + db.query( + Parts.id.label("id"), + Parts.part_number, + Parts.description, + Parts.price, + 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)) + .order_by(Parts.part_number) + ) + + results = [] + running_total = 0.0 + for row in query.all(): + price = row.price or 0 + quantity = row.quantity or 0 + total = price * quantity + running_total += total + results.append({ + "part_number": row.part_number, + "description": row.description, + "price": price, + "quantity": quantity, + "total": total, + "running_total": running_total, + }) + + html_template = Template(""" + + + + + +

Parts Usage Report

+

+ From: + {{ from_month }}   + To: + {{ to_month }} +

+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
+ + + """) + + html_content = html_template.render( + rows=results, + from_month=from_month, + to_month=to_month + ) + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=parts_used_report.pdf" + }, + ) From 2af0b36ba67cfc016ea92c45cbd6caa241ceed53 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 09:39:54 -0500 Subject: [PATCH 036/146] [maintenance] Update get_maintenance_summary --- api/routes/maintenance.py | 88 +++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index f590ee3c..2efb45ea 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -9,16 +9,15 @@ from weasyprint import HTML from io import BytesIO from jinja2 import Template +from collections import defaultdict from api.models.main_models import ( Parts, PartsUsed, - PartAssociation, - PartTypeLU, + Users, Meters, - MeterTypeLU, - meterRegisters, MeterActivities, + ActivityTypeLU, ) from api.session import get_db from api.enums import ScopedUser @@ -61,14 +60,10 @@ def get_maintenance_summary( db: Session = Depends(get_db), ): try: - # Parse and normalize start of "from" month from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) - - # Determine end of "to" month to_dt = datetime.strptime(to_month, "%Y-%m") year, month = to_dt.year, to_dt.month today = datetime.now() - if year == today.year and month == today.month: to_date = today else: @@ -85,51 +80,64 @@ def get_maintenance_summary( detail="Invalid date format. Use YYYY-MM." ) - usage_subq = ( + # Base query + base_query = ( db.query( - PartsUsed.c.part_id.label("used_part_id"), - func.count(PartsUsed.c.part_id).label("quantity") + MeterActivities.timestamp_start.label("date_time"), + Users.full_name.label("technician"), + Meters.serial_number.label("meter"), + ActivityTypeLU.name.label("activity_type") ) + .join(Users, Users.id == MeterActivities.submitting_user_id) + .join(Meters, Meters.id == MeterActivities.meter_id) .join( - MeterActivities, - MeterActivities.id == PartsUsed.c.meter_activity_id - ) + ActivityTypeLU, + ActivityTypeLU.id == MeterActivities.activity_type_id + ) .filter( MeterActivities.timestamp_start >= from_date, MeterActivities.timestamp_start <= to_date, - PartsUsed.c.part_id.in_(parts), + MeterActivities.submitting_user_id.in_(technicians) ) - .group_by(PartsUsed.c.part_id) - .subquery() + .order_by(MeterActivities.timestamp_start) + .all() ) - query = ( - db.query( - Parts.id.label("id"), - Parts.part_number, - Parts.description, - Parts.price, - 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)) - .order_by(Parts.part_number) + # Aggregations + repairs_by_meter = defaultdict(int) + pms_by_meter = defaultdict(int) + grouped_rows = defaultdict( + lambda: {"number_of_repairs": 0, "number_of_pms": 0} ) - results = [] - for row in query.all(): - price = row.price or 0 - total = price * row.quantity - results.append({ - "id": row.id, - "part_number": row.part_number, - "description": row.description, - "price": price, - "quantity": row.quantity, - "total": total, + for row in base_query: + key = (row.date_time, row.technician, row.meter) + if row.activity_type == "Repair": + repairs_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_repairs"] += 1 + elif row.activity_type == "Preventative Maintenance": + pms_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_pms"] += 1 + + # Serialize grouped data + repairs_result = [{"meter": meter, "count": count} for meter, count in repairs_by_meter.items()] + pms_result = [{"meter": meter, "count": count} for meter, count in pms_by_meter.items()] + + table_rows = [] + for (date_time, technician, meter), counts in grouped_rows.items(): + table_rows.append({ + "date_time": date_time, + "technician": technician, + "meter": meter, + "number_of_repairs": counts["number_of_repairs"], + "number_of_pms": counts["number_of_pms"], }) - return results + return { + "repairs_by_meter": repairs_result, + "pms_by_meter": pms_result, + "table_rows": table_rows, + } @part_router.get( From 838b24ea8157c7b8d0110f7a737c7c7bc0a9dd0d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 09:43:12 -0500 Subject: [PATCH 037/146] [maintenance] Add matplotlib for pie chart --- api/routes/maintenance.py | 177 +++++++++++++++++++++----------------- 1 file changed, 97 insertions(+), 80 deletions(-) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 2efb45ea..31346206 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -1,6 +1,5 @@ from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session -from sqlalchemy import func from pydantic import BaseModel from typing import List from datetime import datetime @@ -10,10 +9,10 @@ from io import BytesIO from jinja2 import Template from collections import defaultdict +from matplotlib.pyplot import figure, close +from base64 import b64encode from api.models.main_models import ( - Parts, - PartsUsed, Users, Meters, MeterActivities, @@ -24,8 +23,6 @@ part_router = APIRouter() -# --- Pydantic response models --- - class MeterSummary(BaseModel): meter: str @@ -157,108 +154,127 @@ def download_parts_used_pdf( to_dt = datetime.strptime(to_month, "%Y-%m") year, month = to_dt.year, to_dt.month today = datetime.now() - if year == today.year and month == today.month: to_date = today else: last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace( - day=last_day, - hour=23, - minute=59, - second=59 - ) + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) except ValueError: - raise HTTPException( - status_code=400, - detail="Invalid date format. Use YYYY-MM." - ) + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - usage_subq = ( + base_query = ( db.query( - PartsUsed.c.part_id.label("used_part_id"), - func.count(PartsUsed.c.part_id).label("quantity") + MeterActivities.timestamp_start.label("date_time"), + Users.full_name.label("technician"), + Meters.serial_number.label("meter"), + ActivityTypeLU.name.label("activity_type") ) - .join( - MeterActivities, - MeterActivities.id == PartsUsed.c.meter_activity_id - ) + .join(Users, Users.id == MeterActivities.submitting_user_id) + .join(Meters, Meters.id == MeterActivities.meter_id) + .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id) .filter( MeterActivities.timestamp_start >= from_date, MeterActivities.timestamp_start <= to_date, - PartsUsed.c.part_id.in_(parts), + MeterActivities.submitting_user_id.in_(technicians) ) - .group_by(PartsUsed.c.part_id) - .subquery() + .order_by(MeterActivities.timestamp_start) + .all() ) - query = ( - db.query( - Parts.id.label("id"), - Parts.part_number, - Parts.description, - Parts.price, - 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)) - .order_by(Parts.part_number) - ) + repairs_by_meter = defaultdict(int) + pms_by_meter = defaultdict(int) + grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) - results = [] - running_total = 0.0 - for row in query.all(): - price = row.price or 0 - quantity = row.quantity or 0 - total = price * quantity - running_total += total - results.append({ - "part_number": row.part_number, - "description": row.description, - "price": price, - "quantity": quantity, - "total": total, - "running_total": running_total, + for row in base_query: + key = (row.date_time, row.technician, row.meter) + if row.activity_type == "Repair": + repairs_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_repairs"] += 1 + elif row.activity_type == "Preventative Maintenance": + pms_by_meter[row.meter] += 1 + grouped_rows[key]["number_of_pms"] += 1 + + table_rows = [] + for (date_time, technician, meter), counts in grouped_rows.items(): + table_rows.append({ + "date_time": date_time.strftime("%Y-%m-%d %H:%M"), + "technician": technician, + "meter": meter, + "number_of_repairs": counts["number_of_repairs"], + "number_of_pms": counts["number_of_pms"], }) + # Helper: create pie chart image as base64 + def make_pie_chart(data: dict, title: str): + if not data: + return "" + fig = figure(figsize=(5, 5)) + ax = fig.add_subplot(111) + ax.pie( + data.values(), + labels=data.keys(), + autopct="%1.1f%%", + startangle=140, + ) + ax.set_title(title) + buf = BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight") + close(fig) + return b64encode(buf.getvalue()).decode("utf-8") + + repair_chart_b64 = make_pie_chart(repairs_by_meter, "Repairs by Meter") + pm_chart_b64 = make_pie_chart(pms_by_meter, "Preventative Maintenances by Meter") + + # Jinja2 template html_template = Template(""" -

Parts Usage Report

-

- From: - {{ from_month }}   - To: - {{ to_month }} -

+

Maintenance Summary

+

From: {{ from_month }}    To: {{ to_month }}

+ + {% if repair_chart %} +
+

Repairs by Meter

+ +
+ {% endif %} + + {% if pm_chart %} +
+

Preventative Maintenance by Meter

+ +
+ {% endif %} + +

Detailed Activity Table

- - - - - - + + + + + - {% for row in rows %} + {% for row in table_rows %} - - - - - - + + + + + {% endfor %} @@ -267,19 +283,20 @@ def download_parts_used_pdf( """) - html_content = html_template.render( - rows=results, + html = html_template.render( from_month=from_month, - to_month=to_month + to_month=to_month, + repair_chart=repair_chart_b64, + pm_chart=pm_chart_b64, + table_rows=table_rows, ) + pdf_io = BytesIO() - HTML(string=html_content).write_pdf(pdf_io) + HTML(string=html).write_pdf(pdf_io) pdf_io.seek(0) return StreamingResponse( pdf_io, media_type="application/pdf", - headers={ - "Content-Disposition": "attachment; filename=parts_used_report.pdf" - }, + headers={"Content-Disposition": "attachment; filename=maintenance_summary.pdf"}, ) From b7e4de43f48b3545076d175e567aacdead023681 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 09:54:22 -0500 Subject: [PATCH 038/146] [Maintenance] Update frontend to call backend endpoint --- .../src/views/Reports/Maintenance/index.tsx | 74 +++++++++++++++---- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index bafa6ce2..7860b88c 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -93,28 +93,76 @@ export const MaintenanceReportView = () => { cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours }); - const { control, reset, setValue } = useForm({ + const { control, reset, setValue, watch } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, }); + const from = watch("from"); + const to = watch("to"); + const technicians = watch("techicians"); + const trss = watch("trss"); + const technicianOptions = useMemo(() => { const base = techiciansQuery.data ?? []; - console.log({ base }); return [...base, allTechniciansOption]; }, [techiciansQuery.data]); - const numberOfRepairsPieChartData = [ - { value: 55, label: "Meter A" }, - { value: 10, label: "Meter B" }, - { value: 15, label: "Meter C" }, - ]; + const dataQuery = useQuery({ + queryKey: ["Inventory", "report", "maintenance", from, to, technicians], + queryFn: async () => { + const queryParams = new URLSearchParams({ + from_month: from?.format("YYYY-MM"), + to_month: to?.format("YYYY-MM"), + trss, + ...technicians?.reduce( + (acc, id) => { + acc["technicians"] = acc["technicians"] || []; + acc["technicians"].push(id.toString()); + return acc; + }, + {} as Record, + ), + }).toString(); - const numberOfPMsPieChartData = [ - { value: 5, label: "Meter A" }, - { value: 15, label: "Meter B" }, - { value: 20, label: "Meter C" }, - ]; + const response = await fetch(`${API_URL}/maintenance?${queryParams}`, { + headers: { Authorization: authHeader() }, + }); + if (!response.ok) { + throw new Error("Failed to fetch maintenance data"); + } + return response.json(); + }, + staleTime: 1000 * 60 * 60 * 24, + cacheTime: 1000 * 60 * 60 * 24, + }); + + const numberOfRepairsPieChartData = useMemo(() => { + return ( + dataQuery.data?.repairs_by_meter?.map((item: any) => ({ + label: item.meter, + value: item.count, + })) ?? [] + ); + }, [dataQuery.data]); + + const numberOfPMsPieChartData = useMemo(() => { + return ( + dataQuery.data?.pms_by_meter?.map((item: any) => ({ + label: item.meter, + value: item.count, + })) ?? [] + ); + }, [dataQuery.data]); + + const tableRows = useMemo(() => { + return ( + dataQuery.data?.table_rows?.map((row: any, index: number) => ({ + id: index, + ...row, + })) ?? [] + ); + }, [dataQuery.data]); const columns: GridColDef[] = [ { field: "date_time", headerName: "Date / Time", flex: 1 }, @@ -306,7 +354,7 @@ export const MaintenanceReportView = () => { Date: Wed, 25 Jun 2025 10:50:27 -0500 Subject: [PATCH 039/146] [api/main] Fix broken route bug --- api/main.py | 2 ++ api/routes/maintenance.py | 6 ++-- .../src/views/Reports/Maintenance/index.tsx | 34 ++++++++++--------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/api/main.py b/api/main.py index 8066b3d9..cec8cf13 100644 --- a/api/main.py +++ b/api/main.py @@ -28,6 +28,7 @@ from api.routes.activities import activity_router from api.routes.OSE import ose_router from api.routes.parts import part_router +from api.routes.maintenance import maintenance_router from api.routes.admin import admin_router from api.routes.wells import well_router @@ -130,6 +131,7 @@ def login_for_access_token( authenticated_router.include_router(activity_router) authenticated_router.include_router(well_measurement_router) authenticated_router.include_router(part_router) +authenticated_router.include_router(maintenance_router) authenticated_router.include_router(admin_router) authenticated_router.include_router(well_router) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 31346206..fa1586af 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -21,7 +21,7 @@ from api.session import get_db from api.enums import ScopedUser -part_router = APIRouter() +maintenance_router = APIRouter() class MeterSummary(BaseModel): @@ -43,7 +43,7 @@ class MaintenanceSummaryResponse(BaseModel): table_rows: List[MaintenanceRow] -@part_router.get( +@maintenance_router.get( "/maintenance", tags=["Maintenance"], response_model=MaintenanceSummaryResponse, @@ -137,7 +137,7 @@ def get_maintenance_summary( } -@part_router.get( +@maintenance_router.get( "/maintenance/pdf", tags=["Maintenance"], dependencies=[Depends(ScopedUser.Read)], diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 7860b88c..966e9f61 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -111,26 +111,28 @@ export const MaintenanceReportView = () => { const dataQuery = useQuery({ queryKey: ["Inventory", "report", "maintenance", from, to, technicians], queryFn: async () => { - const queryParams = new URLSearchParams({ - from_month: from?.format("YYYY-MM"), - to_month: to?.format("YYYY-MM"), - trss, - ...technicians?.reduce( - (acc, id) => { - acc["technicians"] = acc["technicians"] || []; - acc["technicians"].push(id.toString()); - return acc; - }, - {} as Record, - ), - }).toString(); + const queryParams = new URLSearchParams(); + queryParams.set("from_month", from?.format("YYYY-MM")); + queryParams.set("to_month", to?.format("YYYY-MM")); + queryParams.set("trss", trss ?? ""); + + technicians + ?.map((t) => t.id) + .forEach((id) => { + queryParams.append("technicians", id.toString()); + }); + + const response = await fetch( + `${API_URL}/maintenance?${queryParams.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); - const response = await fetch(`${API_URL}/maintenance?${queryParams}`, { - headers: { Authorization: authHeader() }, - }); if (!response.ok) { throw new Error("Failed to fetch maintenance data"); } + return response.json(); }, staleTime: 1000 * 60 * 60 * 24, From cce3ffdca114aabe3b0bfd42b63633c520d21c0e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 10:58:51 -0500 Subject: [PATCH 040/146] [maintenance] Add guard to handle if all technicians were selected --- api/routes/maintenance.py | 46 ++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index fa1586af..de08f0a8 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -77,8 +77,10 @@ def get_maintenance_summary( detail="Invalid date format. Use YYYY-MM." ) - # Base query - base_query = ( + # If -1 is in the list, remove technician filtering (include all) + filter_techs = -1 not in technicians + + query = ( db.query( MeterActivities.timestamp_start.label("date_time"), Users.full_name.label("technician"), @@ -90,16 +92,18 @@ def get_maintenance_summary( .join( ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id - ) - .filter( - MeterActivities.timestamp_start >= from_date, - MeterActivities.timestamp_start <= to_date, - MeterActivities.submitting_user_id.in_(technicians) ) - .order_by(MeterActivities.timestamp_start) - .all() + .filter(MeterActivities.timestamp_start >= from_date) + .filter(MeterActivities.timestamp_start <= to_date) ) + if filter_techs: + query = query.filter( + MeterActivities.submitting_user_id.in_(technicians) + ) + + base_query = query.order_by(MeterActivities.timestamp_start).all() + # Aggregations repairs_by_meter = defaultdict(int) pms_by_meter = defaultdict(int) @@ -162,7 +166,10 @@ def download_parts_used_pdf( except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - base_query = ( + # If -1 is in the list, remove technician filtering (include all) + filter_techs = -1 not in technicians + + query = ( db.query( MeterActivities.timestamp_start.label("date_time"), Users.full_name.label("technician"), @@ -171,16 +178,21 @@ def download_parts_used_pdf( ) .join(Users, Users.id == MeterActivities.submitting_user_id) .join(Meters, Meters.id == MeterActivities.meter_id) - .join(ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id) - .filter( - MeterActivities.timestamp_start >= from_date, - MeterActivities.timestamp_start <= to_date, - MeterActivities.submitting_user_id.in_(technicians) + .join( + ActivityTypeLU, + ActivityTypeLU.id == MeterActivities.activity_type_id ) - .order_by(MeterActivities.timestamp_start) - .all() + .filter(MeterActivities.timestamp_start >= from_date) + .filter(MeterActivities.timestamp_start <= to_date) ) + if filter_techs: + query = query.filter( + MeterActivities.submitting_user_id.in_(technicians) + ) + + base_query = query.order_by(MeterActivities.timestamp_start).all() + repairs_by_meter = defaultdict(int) pms_by_meter = defaultdict(int) grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) From 114c46623475b901d454f8bd35ae70648fff1265 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 11:08:53 -0500 Subject: [PATCH 041/146] [Maintenance] Fix broken table column --- frontend/src/views/Reports/Maintenance/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 966e9f61..02f20c37 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -170,7 +170,7 @@ export const MaintenanceReportView = () => { { field: "date_time", headerName: "Date / Time", flex: 1 }, { field: "technician", headerName: "Technician", flex: 1 }, { - field: "number_of_repais", + field: "number_of_repairs", headerName: "Number of Repairs", type: "number", flex: 1, From 3a52266950bddab7ac21c1347b7f1f6f333d208a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 11:33:54 -0500 Subject: [PATCH 042/146] [Maintenance] Force non-GUI for plot creation --- api/routes/maintenance.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index de08f0a8..78470233 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -11,7 +11,6 @@ from collections import defaultdict from matplotlib.pyplot import figure, close from base64 import b64encode - from api.models.main_models import ( Users, Meters, @@ -21,6 +20,9 @@ from api.session import get_db from api.enums import ScopedUser +import matplotlib +matplotlib.use("Agg") # Force non-GUI backend + maintenance_router = APIRouter() From 90d6c66edc1d4c442784588475e45bc4bcd6672a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 25 Jun 2025 11:34:35 -0500 Subject: [PATCH 043/146] [Maintenance] Add func to download the pdf version --- .../src/views/Reports/Maintenance/index.tsx | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index 02f20c37..b34e1217 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -17,7 +17,7 @@ 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 { useQuery } from "react-query"; +import { useMutation, useQuery } from "react-query"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs, { Dayjs } from "dayjs"; @@ -137,6 +137,7 @@ export const MaintenanceReportView = () => { }, staleTime: 1000 * 60 * 60 * 24, cacheTime: 1000 * 60 * 60 * 24, + enabled: Boolean(from && to && technicians && technicians.length > 0), }); const numberOfRepairsPieChartData = useMemo(() => { @@ -188,6 +189,55 @@ export const MaintenanceReportView = () => { }, ]; + const downloadMaintenancePDFMutation = useMutation({ + mutationFn: async ({ + from, + to, + technicians, + }: { + from: Dayjs; + to: Dayjs; + technicians: number[]; + }) => { + const params = new URLSearchParams({ + from_month: from.format("YYYY-MM"), + to_month: to.format("YYYY-MM"), + trss: "", // optional — if unused you can remove it on both ends + }); + + technicians.forEach((id) => params.append("technicians", id.toString())); + + const response = await fetch( + `${API_URL}/maintenance/pdf?${params.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("PDF generation failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "maintenance_summary.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const handleDownloadMaintenancePDF = () => { + if (!from || !to || !technicians?.length) return; + + downloadMaintenancePDFMutation.mutate({ + from, + to, + technicians: technicians?.map((t) => t.id), + }); + }; + return ( @@ -205,7 +255,14 @@ export const MaintenanceReportView = () => { - + From cbd39e491358ddc422a0fdbf775f18727004ab7a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 26 Jun 2025 14:51:53 -0500 Subject: [PATCH 044/146] [requirements] Use pipreqs to regenerate requirements based on imports --- api/requirements.txt | 64 ++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 38 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index e506b2ac..9a23e2d0 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,43 +1,31 @@ -annotated-types==0.6.0 -anyio==3.7.1 -bcrypt==4.0.1 -cffi==1.16.0 -click==8.1.7 -colorama==0.4.6 -cryptography==41.0.7 -dnspython==2.4.2 -ecdsa==0.18.0 -email-validator==2.1.0.post1 -exceptiongroup==1.2.0 -fastapi==0.105.0 -fastapi-pagination==0.12.14 +attr==0.3.2 +ConfigParser==7.2.0 +cryptography==45.0.4 +docutils==0.21.2 +fastapi==0.115.14 +fastapi_pagination==0.13.3 +filelock==3.18.0 GeoAlchemy2==0.14.2 -greenlet==3.0.2 -h11==0.14.0 -httptools==0.6.1 -idna==3.6 +HTMLParser==0.0.2 +ipython==8.12.3 +ipywidgets==8.1.7 Jinja2==3.1.6 -packaging==23.2 +jnius==1.1.0 +keyring==25.6.0 +matplotlib==3.10.3 passlib==1.7.4 -psycopg==3.1.16 -psycopg-binary==3.1.16 -pyasn1==0.5.1 -pycparser==2.21 -pydantic==2.5.2 -pydantic_core==2.14.5 -python-dotenv==1.0.0 -python-jose==3.3.0 -python-multipart==0.0.6 -PyYAML==6.0.1 -rsa==4.9 -six==1.16.0 -sniffio==1.3.0 +protobuf==6.31.1 +pydantic==2.11.7 +pyOpenSSL==25.1.0 +pytest==8.4.1 +python-dotenv==1.1.1 +python_jose==3.3.0 +redis==6.2.0 +Sphinx==8.2.3 SQLAlchemy==2.0.23 -starlette==0.27.0 -typing_extensions==4.9.0 -tzdata==2023.3 -uvicorn==0.25.0 -watchfiles==0.21.0 -websockets==12.0 +starlette==0.47.1 +thread==2.0.5 +urllib3_secure_extra==0.1.0 weasyprint==65.1 - +xlsxwriter==3.2.5 +xmlrpclib==1.0.1 From 2537c0e509a33a895d76c9195074124b24524c07 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 27 Jun 2025 10:48:26 -0500 Subject: [PATCH 045/146] [parts] Update report to only show in_use parts as options --- api/routes/parts.py | 17 ++++++++++++++--- frontend/src/views/Reports/PartsUsed/index.tsx | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/api/routes/parts.py b/api/routes/parts.py index 8bb99e71..f29a0b49 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -1,7 +1,7 @@ from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy.orm import Session, joinedload from sqlalchemy import select, func -from typing import List, Union +from typing import List, Union, Optional from datetime import datetime import calendar from fastapi.responses import StreamingResponse @@ -34,8 +34,19 @@ dependencies=[Depends(ScopedUser.Read)], tags=["Parts"], ) -def get_parts(db: Session = Depends(get_db)): - return db.scalars(select(Parts).options(joinedload(Parts.part_type))).all() +def get_parts( + db: Session = Depends(get_db), + in_use: Optional[bool] = Query( + None, + description="Filter by in_use status" + ), +): + stmt = select(Parts).options(joinedload(Parts.part_type)) + + if in_use is not None: + stmt = stmt.where(Parts.in_use == in_use) + + return db.scalars(stmt).all() @part_router.get( diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index f938a398..4bba11d4 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -102,7 +102,7 @@ export const PartsUsedReportView = () => { const partsQuery = useQuery({ queryKey: ["Inventory", "report", "partslist"], queryFn: async () => { - const response = await fetch(`${API_URL}/parts`, { + const response = await fetch(`${API_URL}/parts?in_use=true`, { headers: { Authorization: authHeader() }, }); if (!response.ok) { From ab9c74700c16930caf2fff2406f97eff016dbfbb Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 27 Jun 2025 11:01:06 -0500 Subject: [PATCH 046/146] [PartsUsed] Update partType => partTypes (now multi-select) --- .../RHControlled/ControlledSelect.tsx | 102 +++++++++++------- .../src/views/Reports/PartsUsed/index.tsx | 51 +++++---- 2 files changed, 94 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledSelect.tsx b/frontend/src/components/RHControlled/ControlledSelect.tsx index 81294d96..696cf9f8 100644 --- a/frontend/src/components/RHControlled/ControlledSelect.tsx +++ b/frontend/src/components/RHControlled/ControlledSelect.tsx @@ -11,52 +11,78 @@ export function ControlledSelect({ control, name, size = "small", + multiple = false, ...childProps }: any) { return ( ( - - {childProps.label} - + isMultiple + ? childProps.options + .filter((opt: any) => selected.includes(opt.id)) + .map((opt: any) => childProps.getOptionLabel(opt)) + .join(", ") + : childProps.getOptionLabel( + childProps.options.find( + (opt: any) => opt.id === selected, + ) ?? {}, + ) + } + > + {childProps.options.map((option: any) => ( + + {childProps.getOptionLabel(option)} + + ))} + {childProps.value === "Loading..." && ( + Loading... + )} + + {childProps.error && ( + {childProps.error} )} - - {childProps.error && ( - - {childProps.error} - - )} - - )} + + ); + }} /> ); } diff --git a/frontend/src/views/Reports/PartsUsed/index.tsx b/frontend/src/views/Reports/PartsUsed/index.tsx index 4bba11d4..7d58e3ac 100644 --- a/frontend/src/views/Reports/PartsUsed/index.tsx +++ b/frontend/src/views/Reports/PartsUsed/index.tsx @@ -65,19 +65,21 @@ const schema = yup.object().shape({ const { from } = this.parent; return !from || !value || dayjs(value).isAfter(dayjs(from)); }), - part_type: yup - .object() - .shape({ - id: yup.number().nullable(), - type: yup - .object() - .shape({ - id: yup.number().nullable(), - name: yup.string().nullable(), - description: yup.string().nullable(), - }) - .nullable(), - }) + part_types: yup + .array() + .of( + yup.object().shape({ + id: yup.number().nullable(), + type: yup + .object() + .shape({ + id: yup.number().nullable(), + name: yup.string().nullable(), + description: yup.string().nullable(), + }) + .nullable(), + }), + ) .nullable(), parts: yup .array() @@ -88,7 +90,7 @@ const schema = yup.object().shape({ const defaultSchema = { from: dayjs(), to: dayjs(), - part_type: null, + part_types: [], parts: [], }; @@ -117,14 +119,20 @@ export const PartsUsedReportView = () => { const from = watch("from"); const to = watch("to"); const selectedPartIds = watch("parts") ?? []; - const partType = watch("part_type"); + const partTypes = watch("part_types"); const filteredParts = useMemo(() => { if (!partsQuery.data) return []; - return partType - ? partsQuery.data.filter((p) => p.part_type_id === partType.id) - : partsQuery.data; - }, [partsQuery.data, partType]); + + if (Array.isArray(partTypes) && partTypes.length > 0) { + const selectedIds = partTypes.map((pt) => pt.id); + return partsQuery.data.filter((p) => + selectedIds.includes(p.part_type_id), + ); + } + + return partsQuery.data; + }, [partsQuery.data, partTypes]); useEffect(() => { const currentParts = watch("parts") ?? []; @@ -135,7 +143,7 @@ export const PartsUsedReportView = () => { // Drop invalid part IDs reset({ ...watch(), parts: stillValid }); } - }, [partType, filteredParts]); + }, [partTypes, filteredParts]); const partsUsedQuery = useQuery({ queryKey: ["Inventory", "report", "partsused", from, to, selectedPartIds], @@ -326,7 +334,8 @@ export const PartsUsedReportView = () => { control={control} sx={{ minWidth: "15rem" }} size="medium" - name="part_type" + name="part_types" + multiple disabled={partsQuery.isFetching} options={[ ...new Map( From 1b3adbdeb8233cb4dd7a20f80555e9b5da2a6ebd Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 27 Jun 2025 11:55:00 -0500 Subject: [PATCH 047/146] [/templates] Refactor pdf templates to their own files --- api/routes/maintenance.py | 71 +++++------------------- api/routes/parts.py | 60 ++++----------------- api/templates/maintenance_summary.html | 74 ++++++++++++++++++++++++++ api/templates/parts_used_report.html | 56 +++++++++++++++++++ 4 files changed, 153 insertions(+), 108 deletions(-) create mode 100644 api/templates/maintenance_summary.html create mode 100644 api/templates/parts_used_report.html diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 78470233..4e1f4d95 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -19,10 +19,20 @@ ) from api.session import get_db from api.enums import ScopedUser +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape import matplotlib matplotlib.use("Agg") # Force non-GUI backend + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) + maintenance_router = APIRouter() @@ -239,65 +249,8 @@ def make_pie_chart(data: dict, title: str): repair_chart_b64 = make_pie_chart(repairs_by_meter, "Repairs by Meter") pm_chart_b64 = make_pie_chart(pms_by_meter, "Preventative Maintenances by Meter") - # Jinja2 template - html_template = Template(""" - - - - - -

Maintenance Summary

-

From: {{ from_month }}    To: {{ to_month }}

- - {% if repair_chart %} -
-

Repairs by Meter

- -
- {% endif %} - - {% if pm_chart %} -
-

Preventative Maintenance by Meter

- -
- {% endif %} - -

Detailed Activity Table

-
Part #DescriptionPriceQuantityTotalRunning TotalDate / TimeTechnicianMeterNumber of RepairsNumber of Preventative Maintenances
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}{{ row.date_time }}{{ row.technician }}{{ row.meter }}{{ row.number_of_repairs }}{{ row.number_of_pms }}
- - - - - - - - - - - {% for row in table_rows %} - - - - - - - - {% endfor %} - -
Date / TimeTechnicianMeterNumber of RepairsNumber of Preventative Maintenances
{{ row.date_time }}{{ row.technician }}{{ row.meter }}{{ row.number_of_repairs }}{{ row.number_of_pms }}
- - - """) - - html = html_template.render( + template = templates.get_template("maintenance_summary.html") + html = template.render( from_month=from_month, to_month=to_month, repair_chart=repair_chart_b64, diff --git a/api/routes/parts.py b/api/routes/parts.py index f29a0b49..3eb9325c 100644 --- a/api/routes/parts.py +++ b/api/routes/parts.py @@ -7,8 +7,6 @@ from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO -from jinja2 import Template - from api.models.main_models import ( Parts, PartsUsed, @@ -24,6 +22,15 @@ from api.route_util import _get from api.enums import ScopedUser from sqlalchemy.exc import IntegrityError +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) part_router = APIRouter() @@ -212,53 +219,8 @@ def download_parts_used_pdf( "running_total": running_total, }) - html_template = Template(""" - - - - - -

Parts Usage Report

-

- From: - {{ from_month }}   - To: - {{ to_month }} -

- - - - - - - - - - - - - {% for row in rows %} - - - - - - - - - {% endfor %} - -
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
- - - """) - - html_content = html_template.render( + template = templates.get_template("parts_used_report.html") + html_content = template.render( rows=results, from_month=from_month, to_month=to_month diff --git a/api/templates/maintenance_summary.html b/api/templates/maintenance_summary.html new file mode 100644 index 00000000..8bc44978 --- /dev/null +++ b/api/templates/maintenance_summary.html @@ -0,0 +1,74 @@ + + + + + +

Maintenance Summary

+

+ From: {{ from_month }}    + To: {{ to_month }} +

+ + {% if repair_chart %} +
+

Repairs by Meter

+ +
+ {% endif %} {% if pm_chart %} +
+

Preventative Maintenance by Meter

+ +
+ {% endif %} + +

Detailed Activity Table

+ + + + + + + + + + + + {% for row in table_rows %} + + + + + + + + {% endfor %} + +
Date / TimeTechnicianMeterNumber of RepairsNumber of Preventative Maintenances
{{ row.date_time }}{{ row.technician }}{{ row.meter }}{{ row.number_of_repairs }}{{ row.number_of_pms }}
+ + diff --git a/api/templates/parts_used_report.html b/api/templates/parts_used_report.html new file mode 100644 index 00000000..d08ba4c4 --- /dev/null +++ b/api/templates/parts_used_report.html @@ -0,0 +1,56 @@ + + + + + +

Parts Usage Report

+

+ From: + {{ from_month }}   + To: + {{ to_month }} +

+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
Part #DescriptionPriceQuantityTotalRunning Total
{{ row.part_number }}{{ row.description }}${{ "%.2f"|format(row.price) }}{{ row.quantity }}${{ "%.2f"|format(row.total) }}${{ "%.2f"|format(row.running_total) }}
+ + From 1c3f7a6e35ff91c80296814b9a0e7967eb104fb5 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 27 Jun 2025 12:40:42 -0500 Subject: [PATCH 048/146] [maintenance] Add support for trss filtering --- api/routes/maintenance.py | 97 +++++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 4e1f4d95..6078cf9a 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -7,7 +7,6 @@ from fastapi.responses import StreamingResponse from weasyprint import HTML from io import BytesIO -from jinja2 import Template from collections import defaultdict from matplotlib.pyplot import figure, close from base64 import b64encode @@ -16,6 +15,7 @@ Meters, MeterActivities, ActivityTypeLU, + Locations, ) from api.session import get_db from api.enums import ScopedUser @@ -68,30 +68,56 @@ def get_maintenance_summary( technicians: List[int] = Query(...), db: Session = Depends(get_db), ): + # Parse from/to month into datetime range try: from_date = datetime.strptime(from_month, "%Y-%m").replace(day=1) to_dt = datetime.strptime(to_month, "%Y-%m") year, month = to_dt.year, to_dt.month today = datetime.now() + if year == today.year and month == today.month: to_date = today else: last_day = calendar.monthrange(year, month)[1] - to_date = to_dt.replace( - day=last_day, - hour=23, - minute=59, - second=59 - ) + to_date = to_dt.replace(day=last_day, hour=23, minute=59, second=59) except ValueError: raise HTTPException( status_code=400, detail="Invalid date format. Use YYYY-MM." ) - # If -1 is in the list, remove technician filtering (include all) + # Filter by technicians if -1 is not present filter_techs = -1 not in technicians + # Optional TRSS-based meter filtering + matching_meter_ids = None + if trss: + try: + parts = list(map(int, trss.strip().split("."))) + if len(parts) >= 4: + township, range_, section, quarter = parts[:4] + + location_subq = ( + db.query(Locations.id) + .filter( + Locations.township == township, + Locations.range == range_, + Locations.section == section, + Locations.quarter == quarter, + ) + ) + location_ids = [loc_id for (loc_id,) in location_subq.all()] + + if location_ids: + meter_subq = ( + db.query(Meters.id) + .filter(Meters.location_id.in_(location_ids)) + ) + matching_meter_ids = [m_id for (m_id,) in meter_subq.all()] + except Exception: + pass # Ignore invalid TRSS input silently + + # Base query query = ( db.query( MeterActivities.timestamp_start.label("date_time"), @@ -114,14 +140,22 @@ def get_maintenance_summary( MeterActivities.submitting_user_id.in_(technicians) ) + if matching_meter_ids is not None: + if not matching_meter_ids: + # TRSS valid but no meters matched → return empty results + return { + "repairs_by_meter": [], + "pms_by_meter": [], + "table_rows": [], + } + query = query.filter(MeterActivities.meter_id.in_(matching_meter_ids)) + base_query = query.order_by(MeterActivities.timestamp_start).all() - # Aggregations + # Aggregate repairs and PMs repairs_by_meter = defaultdict(int) pms_by_meter = defaultdict(int) - grouped_rows = defaultdict( - lambda: {"number_of_repairs": 0, "number_of_pms": 0} - ) + grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) for row in base_query: key = (row.date_time, row.technician, row.meter) @@ -132,7 +166,6 @@ def get_maintenance_summary( pms_by_meter[row.meter] += 1 grouped_rows[key]["number_of_pms"] += 1 - # Serialize grouped data repairs_result = [{"meter": meter, "count": count} for meter, count in repairs_by_meter.items()] pms_result = [{"meter": meter, "count": count} for meter, count in pms_by_meter.items()] @@ -178,9 +211,34 @@ def download_parts_used_pdf( except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - # If -1 is in the list, remove technician filtering (include all) filter_techs = -1 not in technicians + # Optional TRSS filtering via Locations → Meters + matching_meter_ids = None + if trss: + try: + parts = list(map(int, trss.strip().split("."))) + if len(parts) >= 4: + township, range_, section, quarter = parts[:4] + + location_ids = [ + loc_id for (loc_id,) in db.query(Locations.id).filter( + Locations.township == township, + Locations.range == range_, + Locations.section == section, + Locations.quarter == quarter, + ).all() + ] + + if location_ids: + matching_meter_ids = [ + meter_id for (meter_id,) in db.query(Meters.id).filter( + Meters.location_id.in_(location_ids) + ).all() + ] + except Exception: + pass # Silently skip TRSS filtering if malformed + query = ( db.query( MeterActivities.timestamp_start.label("date_time"), @@ -199,9 +257,12 @@ def download_parts_used_pdf( ) if filter_techs: - query = query.filter( - MeterActivities.submitting_user_id.in_(technicians) - ) + query = query.filter(MeterActivities.submitting_user_id.in_(technicians)) + + if matching_meter_ids is not None: + if not matching_meter_ids: + return StreamingResponse(BytesIO(), media_type="application/pdf") # Empty PDF + query = query.filter(MeterActivities.meter_id.in_(matching_meter_ids)) base_query = query.order_by(MeterActivities.timestamp_start).all() @@ -228,7 +289,7 @@ def download_parts_used_pdf( "number_of_pms": counts["number_of_pms"], }) - # Helper: create pie chart image as base64 + # Generate pie charts as base64 PNGs def make_pie_chart(data: dict, title: str): if not data: return "" From 90b8280c7b600019362414decb2ed90a7804ef2e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 9 Jul 2025 15:05:42 -0500 Subject: [PATCH 049/146] [MonitoringWells] Fix toplevel UI --- .../views/Reports/MonitoringWells/index.tsx | 19 +++++++------------ frontend/src/views/Reports/index.tsx | 1 - 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 364cbbec..6e42426c 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -18,6 +18,7 @@ import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs from "dayjs"; import { BackgroundBox } from "../../../components/BackgroundBox"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -34,7 +35,7 @@ const defaultSchema = { export const MonitoringWellsReportView = () => { const wellsQuery = useQuery({ queryKey: ["MonitoringWells", "report", "wells"], - queryFn: async () => {}, + queryFn: async () => { }, }); const { control, reset } = useForm({ @@ -45,15 +46,7 @@ export const MonitoringWellsReportView = () => { return ( - - Monitoring Wells Report - - - } - sx={{ mb: 0, pb: 0 }} - /> + @@ -86,6 +79,7 @@ export const MonitoringWellsReportView = () => { label="From" sx={{ minWidth: "15rem" }} control={control} + size="medium" name="from" views={["year", "month"]} openTo="year" @@ -97,6 +91,7 @@ export const MonitoringWellsReportView = () => { label="To" sx={{ minWidth: "15rem" }} control={control} + size="medium" name="to" views={["year", "month"]} openTo="year" @@ -116,9 +111,9 @@ export const MonitoringWellsReportView = () => { return ( ); diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index b25c75fe..92c760bb 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -26,7 +26,6 @@ export const ReportsView = () => { Icon={FormatListBulletedOutlined} /> Date: Wed, 9 Jul 2025 15:22:39 -0500 Subject: [PATCH 050/146] [MonitoringWells] Add DataGrid & LineChart --- .../views/Reports/MonitoringWells/index.tsx | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 6e42426c..50cbd309 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -1,13 +1,15 @@ import { ArrowBack, PictureAsPdf, MonitorHeart } from "@mui/icons-material"; import { + Box, Button, Card, CardContent, - CardHeader, Grid, IconButton, + Stack, TextField, Tooltip, + Typography, } from "@mui/material"; import { Link } from "react-router-dom"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; @@ -19,6 +21,8 @@ import { yupResolver } from "@hookform/resolvers/yup"; import 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"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -32,6 +36,11 @@ const defaultSchema = { wells: "", }; +const size = { + width: 400, + height: 400, +}; + export const MonitoringWellsReportView = () => { const wellsQuery = useQuery({ queryKey: ["MonitoringWells", "report", "wells"], @@ -43,6 +52,22 @@ export const MonitoringWellsReportView = () => { defaultValues: defaultSchema, }); + const tableRows: any[] = [] + const columns: GridColDef[] = [ + { field: "date_time", headerName: "Date / Time", flex: 1 }, + { + field: "depth_to_water", + headerName: "Depth To Water (ft)", + type: "number", + flex: 1, + }, + { + field: "well", + headerName: "Well", + flex: 1, + }, + ]; + return ( @@ -121,7 +146,44 @@ export const MonitoringWellsReportView = () => { /> - + + + + Depth of Water over time + + + + + + + From 0c3f3459144bdbfd0d3b2bb5302e645e493a2b65 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 11 Jul 2025 06:09:33 -0500 Subject: [PATCH 051/146] [MonitoringWells] Add func to pull meters list --- .../views/Reports/MonitoringWells/index.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 50cbd309..d23977f5 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -23,6 +23,9 @@ 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 { useAuthHeader } from "react-auth-kit"; +import { API_URL } from "../../../config"; +import { MeterListDTO, Page } from "../../../interfaces"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -42,9 +45,30 @@ const size = { }; export const MonitoringWellsReportView = () => { - const wellsQuery = useQuery({ + const authHeader = useAuthHeader(); + const wellsQuery = useQuery>({ queryKey: ["MonitoringWells", "report", "wells"], - queryFn: async () => { }, + queryFn: async () => { + const headers = { Authorization: authHeader() }; + const params = new URLSearchParams([ + ["filter_by_status", "Installed"], + ["filter_by_status", "Warehouse"], + ["sort_by", "serial_number"], + ["sort_direction", "asc"], + ["limit", "100"], + ["offset", "0"], + ]); + + const response = await fetch(`${API_URL}/meters?${params.toString()}`, { + headers, + }); + + if (!response.ok) { + throw new Error(response.status.toString()); + } + + return response.json(); + }, }); const { control, reset } = useForm({ @@ -126,10 +150,12 @@ export const MonitoringWellsReportView = () => { option?.well?.name ?? ""} + isOptionEqualToValue={(option: MeterListDTO, value: MeterListDTO) => option.id === value.id} renderInput={(params: any) => { if (wellsQuery.isLoading) params.inputProps.value = "Loading..."; From b31ba9a352a2b4a8e5bf0d48324a2eb51571e64e Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 11 Jul 2025 09:53:07 -0500 Subject: [PATCH 052/146] [MonitoredWellsUtils] Mv separateAndSortMonitoredWells func to its own file --- frontend/src/utils/MonitoredWellsUtils.ts | 21 +++++++++++++++++++++ frontend/src/utils/index.ts | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 frontend/src/utils/MonitoredWellsUtils.ts create mode 100644 frontend/src/utils/index.ts diff --git a/frontend/src/utils/MonitoredWellsUtils.ts b/frontend/src/utils/MonitoredWellsUtils.ts new file mode 100644 index 00000000..b14be29b --- /dev/null +++ b/frontend/src/utils/MonitoredWellsUtils.ts @@ -0,0 +1,21 @@ +import { MonitoredWell } from "../interfaces"; + +export const separateAndSortMonitoredWells = ( + wells: MonitoredWell[] = [], +): [MonitoredWell[], MonitoredWell[]] => { + const sortWells = (w: MonitoredWell[]) => + w.slice().sort((a, b) => { + if (!a.name) return 1; // Move undefined/null names to the bottom + if (!b.name) return -1; + return a.name.localeCompare(b.name); + }); + + const outsideRecorderWells = sortWells( + wells.filter((well) => well.outside_recorder === true), + ); + const regularWells = sortWells( + wells.filter((well) => well.outside_recorder !== true), + ); + + return [outsideRecorderWells, regularWells]; +}; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 00000000..43021bbd --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./DateUtils" +export * from "./HttpUtils" +export * from "./MonitoredWellsUtils" From ed3f1d6ac2e87c46b4f72c54774db75874b77c37 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 11 Jul 2025 09:54:04 -0500 Subject: [PATCH 053/146] [views] Refactor MonitoringWellsViews & update autpcomplete multi-select --- .../RHControlled/ControlledAutocomplete.tsx | 54 +++++---- .../MonitoringWells/MonitoringWellsView.tsx | 41 ++----- .../views/Reports/MonitoringWells/index.tsx | 111 ++++++++++-------- 3 files changed, 102 insertions(+), 104 deletions(-) diff --git a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx index 72a56c67..d474020e 100644 --- a/frontend/src/components/RHControlled/ControlledAutocomplete.tsx +++ b/frontend/src/components/RHControlled/ControlledAutocomplete.tsx @@ -8,38 +8,46 @@ const disabledInputStyle = { cursor: "default", }; -// React-Hook-Form controlled version of the autocomplete component export default function ControlledAutocomplete({ control, name, + options = [], + groupBy, + getOptionLabel, + isOptionEqualToValue, + multiple = false, ...childProps }: any) { return ( ( - x} // Disable filtering because backend already does this - isOptionEqualToValue={(a: any, b: any) => { - // Let any value be an option whether or not its in the list - const optionPresent = childProps.options.find( - (x: any) => x.id == b?.id, - ); - if (!optionPresent) { - childProps.options.push(b); - return true; - } - return a?.id == b?.id; - }} - onChange={(_, value) => field.onChange(value)} - /> - )} + defaultValue={multiple ? [] : null} + render={({ field }) => { + const { value, onChange, ...restField } = field; + + const safeValue = multiple + ? Array.isArray(value) + ? value + : [] + : value ?? null; + + return ( + onChange(newValue)} + sx={disabledInputStyle} + {...childProps} + /> + ); + }} /> ); } diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 5d6f3f8d..0d9b87fa 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -38,26 +38,7 @@ import { getDataStreamId } from "../../utils/DataStreamUtils"; import { MonitorHeart } from "@mui/icons-material"; import { BackgroundBox } from "../../components/BackgroundBox"; import { CustomCardHeader } from "../../components/CustomCardHeader"; - -const separateAndSortWells = ( - wells: MonitoredWell[] = [], -): [MonitoredWell[], MonitoredWell[]] => { - const sortWells = (w: MonitoredWell[]) => - w.slice().sort((a, b) => { - if (!a.name) return 1; // Move undefined/null names to the bottom - if (!b.name) return -1; - return a.name.localeCompare(b.name); - }); - - const outsideRecorderWells = sortWells( - wells.filter((well) => well.outside_recorder === true), - ); - const regularWells = sortWells( - wells.filter((well) => well.outside_recorder !== true), - ); - - return [outsideRecorderWells, regularWells]; -}; +import { separateAndSortMonitoredWells } from "../../utils"; export const MonitoringWellsView = () => { const theme = useTheme(); @@ -82,11 +63,7 @@ export const MonitoringWellsView = () => { (s: SecurityScope) => s.scope_string === "admin", ); - const { - data: wells, - isLoading: isLoadingWells, - error: errorWells, - } = useQuery<{ items: MonitoredWell[] }, Error, MonitoredWell[]>({ + const monitoredWellsQuery = useQuery<{ items: MonitoredWell[] }, Error, MonitoredWell[]>({ queryKey: ["wells"], queryFn: () => fetchWithAuth({ @@ -137,7 +114,7 @@ export const MonitoringWellsView = () => { const updateMeasurement = useUpdateWaterLevel(() => refetchManual()); const deleteMeasurement = useDeleteWaterLevel(); - const error = errorWells || errorManual || errorSt2; + const error = monitoredWellsQuery.isError || errorManual || errorSt2; const handleSubmitNewMeasurement = (data: NewWellMeasurement) => { if (wellId) { @@ -179,7 +156,7 @@ export const MonitoringWellsView = () => { setIsUpdateModalOpen(true); }; - const [outsideRecorderWells, regularWells] = separateAndSortWells(wells); + const [outsideRecorderWells, regularWells] = separateAndSortMonitoredWells(monitoredWellsQuery?.data); return ( @@ -194,7 +171,7 @@ export const MonitoringWellsView = () => { Site field.onChange(e.target.value)} + onBlur={field.onBlur} + inputRef={field.ref} + displayEmpty + MenuProps={{ + PaperProps: { style: { maxHeight: 48 * 6.5 + 8, width: 220 } }, + }} + > + + Select a year + + {years.map((year) => ( + + {year} + + ))} + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> +
From 38a57c819b2847155ae68b0678f0e3b237540005 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 14 Aug 2025 13:47:36 -0500 Subject: [PATCH 098/146] [well_measurements] Add suport for comparison year in API --- api/routes/well_measurements.py | 140 +++++++++++++----- .../views/Reports/MonitoringWells/index.tsx | 28 +++- 2 files changed, 119 insertions(+), 49 deletions(-) diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index 21a71f84..d886528e 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -1,6 +1,7 @@ from typing import List, Optional from datetime import datetime import calendar +import re from fastapi import Depends, APIRouter, Query, HTTPException from fastapi.responses import StreamingResponse @@ -77,7 +78,8 @@ def read_waterlevels( to_month: Optional[str] = Query(None, pattern=r"^\d{4}-\d{2}$"), isAveragingAllWells: bool = Query(False), isComparingTo1970Average: bool = Query(False), - db: Session = Depends(get_db) + comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), + db: Session = Depends(get_db), ): MONITORING_USE_TYPE_ID = 11 synthetic_id_counter = -1 @@ -120,6 +122,28 @@ def get_measurements_by_ids(well_ids, start, end): ) return db.scalars(stmt).all() + # Helper: add a comparison average for any given year (same rules as 1970) + def add_year_average(year: int, label: str): + # Determine comparison window shape based on requested range size + if (to_date - from_date).days >= 365: + start = datetime(year, 1, 1) + end = datetime(year, 12, 31, 23, 59, 59) + else: + start = datetime(year, from_date.month, 1) + last_day = calendar.monthrange(year, to_date.month)[1] + end = datetime(year, to_date.month, last_day, 23, 59, 59) + + monitoring_ids = [ + row[0] for row in db.execute( + select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) + ).all() + ] + year_measurements = get_measurements_by_ids(monitoring_ids, start, end) + averaged = group_and_average(year_measurements, "month") # Always by month + for dto in averaged: + dto.well.ra_number = label + response_data.extend(averaged) + # Parse dates from_date, to_date = None, None if from_month and to_month: @@ -135,42 +159,42 @@ def get_measurements_by_ids(well_ids, start, end): except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - if not well_ids and not isComparingTo1970Average: + if not well_ids and not isComparingTo1970Average and not comparisonYear: return [] group_by = "month" if (to_date - from_date).days >= 365 else "day" response_data = [] - # Add average for current selection (if requested) + # Averaged selection (if requested) if isAveragingAllWells and well_ids: current_measurements = get_measurements_by_ids(well_ids, from_date, to_date) averaged = group_and_average(current_measurements, group_by) response_data.extend(averaged) - # Add raw per-well measurements (if not averaging) + # Raw per-well (if not averaging) if not isAveragingAllWells and well_ids: response_data.extend(get_measurements_by_ids(well_ids, from_date, to_date)) - # Add 1970 average comparison (if requested) + # 1970 comparison (existing behavior) if isComparingTo1970Average: - if (to_date - from_date).days >= 365: - start_1970 = datetime(1970, 1, 1) - end_1970 = datetime(1970, 12, 31, 23, 59, 59) - else: - start_1970 = datetime(1970, from_date.month, 1) - last_day = calendar.monthrange(1970, to_date.month)[1] - end_1970 = datetime(1970, to_date.month, last_day, 23, 59, 59) + add_year_average(1970, "1970 Average") - monitoring_ids = [row[0] for row in db.execute( - select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) - ).all()] - measurements_1970 = get_measurements_by_ids(monitoring_ids, start_1970, end_1970) - averaged_1970 = group_and_average(measurements_1970, "month") # Always by month - # Rename to distinguish - for dto in averaged_1970: - dto.well.ra_number = "1970 Average" - response_data.extend(averaged_1970) + # Dynamic comparison year (NEW) + if comparisonYear: + try: + year_int = int(comparisonYear) + except ValueError: + raise HTTPException(status_code=400, detail="comparisonYear must be a 4-digit year") + + current_year = datetime.now().year + if year_int < 1900 or year_int > current_year: + raise HTTPException(status_code=400, detail=f"comparisonYear must be between 1900 and {current_year}") + + # Avoid duplicate if user asked for 1970 both ways + already_added_1970 = isComparingTo1970Average and year_int == 1970 + if not already_added_1970: + add_year_average(year_int, f"{year_int} Average") return response_data @@ -186,7 +210,8 @@ def download_waterlevels_pdf( to_month: str = Query(..., pattern=r"^\d{4}-\d{2}$"), isAveragingAllWells: bool = Query(False), isComparingTo1970Average: bool = Query(False), - db: Session = Depends(get_db) + comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), + db: Session = Depends(get_db), ): MONITORING_USE_TYPE_ID = 11 synthetic_id_counter = -1 @@ -242,7 +267,10 @@ def get_measurements_by_ids(well_ids, start, end): except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM.") - if not well_ids and not isComparingTo1970Average: + # treat "" as not provided + comparisonYear = comparisonYear or None + + if not well_ids and not isComparingTo1970Average and not comparisonYear: raise HTTPException(status_code=400, detail="well_ids is required") group_by = "month" if (to_date - from_date).days >= 365 else "day" @@ -264,22 +292,40 @@ def get_measurements_by_ids(well_ids, start, end): "well_ra_number": m.well.ra_number if m.well else "Unknown" }) - # 1970 Comparison - if isComparingTo1970Average: + # Helper: add comparison average for any given year (same window rules as 1970) + def add_year_average(year: int, label: str): if (to_date - from_date).days >= 365: - start_1970 = datetime(1970, 1, 1) - end_1970 = datetime(1970, 12, 31, 23, 59, 59) + start = datetime(year, 1, 1) + end = datetime(year, 12, 31, 23, 59, 59) else: - start_1970 = datetime(1970, from_date.month, 1) - last_day = calendar.monthrange(1970, to_date.month)[1] - end_1970 = datetime(1970, to_date.month, last_day, 23, 59, 59) + start = datetime(year, from_date.month, 1) + last_day = calendar.monthrange(year, to_date.month)[1] + end = datetime(year, to_date.month, last_day, 23, 59, 59) monitoring_ids = [row[0] for row in db.execute( select(Wells.id).where(Wells.use_type_id == MONITORING_USE_TYPE_ID) ).all()] - measurements_1970 = get_measurements_by_ids(monitoring_ids, start_1970, end_1970) - averaged_1970 = group_and_average(measurements_1970, "month", "1970 Average") - results.extend(averaged_1970) + year_measurements = get_measurements_by_ids(monitoring_ids, start, end) + averaged = group_and_average(year_measurements, "month", label) # Always monthly for comparison + results.extend(averaged) + + # 1970 Comparison + if isComparingTo1970Average: + add_year_average(1970, "1970 Average") + + # Dynamic comparison year + if comparisonYear: + try: + year_int = int(comparisonYear) + except ValueError: + raise HTTPException(status_code=400, detail="comparisonYear must be a 4-digit year") + now_year = datetime.now().year + if year_int < 1900 or year_int > now_year: + raise HTTPException(status_code=400, detail=f"comparisonYear must be between 1900 and {now_year}") + + # avoid duplicate series if user chose 1970 in both mechanisms + if not (isComparingTo1970Average and year_int == 1970): + add_year_average(year_int, f"{year_int} Average") report_title = "ROSWELL ARTESIAN BASIN" report_subtext = None @@ -293,38 +339,50 @@ def get_measurements_by_ids(well_ids, start, end): "ON OR NEAR THE 5TH, 15TH AND 25TH OF EACH MONTH" ) - # Assume from_date is already parsed from_year = from_date.year if from_date else None - def shift_year_safe(dt, new_year: int): """Shift dt to new_year, handling Feb 29 / month-end safely.""" try: return dt.replace(year=new_year) except ValueError: - # Handle Feb 29 or other invalid day in target year last_day = calendar.monthrange(new_year, dt.month)[1] return dt.replace(year=new_year, day=min(dt.day, last_day)) - # Prepare data for table + chart (apply 1970 shifting if requested) + # Prepare data for table + chart (apply timeshift to comparison series) rows = [] data_by_well = defaultdict(list) + + # Precompute which series should be shifted (e.g., "1970 Average", "2021 Average") + shift_years = set() + if isComparingTo1970Average: + shift_years.add(1970) + if comparisonYear: + try: + shift_years.add(int(comparisonYear)) + except ValueError: + pass # already validated above; safe guard + for record in results: original_ts = record["timestamp"] value = record["value"] well_label = record["well_ra_number"] - # Table row: keep original timestamp + # Table rows keep original timestamp rows.append({ "timestamp": original_ts.strftime("%Y-%m-%d %H:%M"), "depth_to_water": value, "well_ra_number": well_label, }) - # Chart point: shift only the 1970 series when comparing chart_ts = original_ts - if isComparingTo1970Average and well_label == "1970 Average" and from_year: - chart_ts = shift_year_safe(original_ts, from_year) + # Detect labels like "1970 Average" or "2021 Average" and shift to from_year + if from_year: + m = re.match(r"^(\d{4}) Average$", well_label) + if m: + yr = int(m.group(1)) + if yr in shift_years: + chart_ts = shift_year_safe(original_ts, from_year) data_by_well[well_label].append((chart_ts, value)) diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 281d253d..4076446c 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -213,26 +213,38 @@ export const MonitoringWellsReportView = () => { const fromYear = dayjs(watch("from")).year(); const isComparingTo1970Average = watch("isComparingTo1970Average"); + const comparisonYear = watch("comparisonYear"); manualMeasurementsQuery?.data?.forEach((m) => { - const wellName = m.well.ra_number; + const wellName = m.well.ra_number as string; + + // Detect series like "1970 Average" or "2021 Average" + const match = /^(\d{4}) Average$/.exec(wellName); + const seriesYear = match ? Number(match[1]) : undefined; - // Modify timestamp if this is the 1970 average line let timestamp = m.timestamp; - if (isComparingTo1970Average && wellName === "1970 Average") { + + // Timeshift ONLY the comparison series that are actually enabled/selected + const shouldShift = + (isComparingTo1970Average && seriesYear === 1970) || + (comparisonYear !== undefined && !Number.isNaN(comparisonYear) && seriesYear === comparisonYear); + + if (shouldShift) { const d = dayjs(timestamp); timestamp = d.set("year", fromYear).toDate(); } if (!groups[wellName]) groups[wellName] = []; - groups[wellName].push({ - x: timestamp, - y: m.value, - }); + groups[wellName].push({ x: timestamp, y: m.value }); }); return groups; - }, [manualMeasurementsQuery?.data, watch("from"), watch("isComparingTo1970Average")]); + }, [ + manualMeasurementsQuery?.data, + watch("from"), + watch("isComparingTo1970Average"), + watch("comparisonYear"), + ]); const allTimestamps = useMemo(() => { const timestamps = new Set(); From b75c9bbdd8a434ffc7044e237a87b9ebe4b48b95 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 14 Aug 2025 16:07:27 -0500 Subject: [PATCH 099/146] [MeterSelectionMap] Add satellite basemap to meter information all meters section --- .../Meters/MeterSelection/MeterSelection.tsx | 2 +- .../MeterSelection/MeterSelectionMap.tsx | 107 ++++++++++-------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index 9b4eed6f..f56f92a2 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -128,7 +128,7 @@ export const MeterSelection = ({ - + { const legend = new L.Control({ position: "bottomleft" }); - legend.onAdd = function () { + legend.onAdd = function() { const div = L.DomUtil.create("div", "info legend"); const seasons = Object.keys(pm_colors); @@ -111,58 +111,63 @@ export default function MeterSelectionMap({ }: MeterSelectionMapProps) { const [meterSearchDebounced] = useDebounce(meterSearch, 250); const [meterMarkersMap, setMeterMarkersMap] = useState([]); - - const mapStyle = { - height: "100%", - width: "100%", - }; - + const mapStyle = { height: "100%", width: "100%" }; const meterMarkers = useGetMeterLocations(meterSearchDebounced); useEffect(() => { setMeterMarkersMap( - meterMarkers.data?.map((meter: MeterMapDTO) => { - return ( - { - onMeterSelection(meter.id); - }, - }} - > - {meter.serial_number} - - ); - }), + meterMarkers.data?.map((meter: MeterMapDTO) => ( + onMeterSelection(meter.id), + }} + > + {meter.serial_number} + + )) ?? [] ); }, [meterMarkers.data]); return ( - - - {meterMarkersMap} - - - - + {/* Base Layers */} + + + + + + + + + {/* Overlays */} + + + {meterMarkersMap} + + + + + ({ @@ -172,10 +177,11 @@ export default function MeterSelectionMap({ fillOpacity: 0, })} /> - - - - + + + + + ({ @@ -184,7 +190,7 @@ export default function MeterSelectionMap({ fillOpacity: 0, })} onEachFeature={(feature, layer) => { - if (feature.properties && feature.properties.TWNSHPLAB) { + if (feature.properties?.TWNSHPLAB) { layer.bindTooltip(feature.properties.TWNSHPLAB, { permanent: true, direction: "center", @@ -193,9 +199,10 @@ export default function MeterSelectionMap({ } }} /> - - + + + ); } From 3e555b3009dd71187ffffab4f0574d94cec0e5ff Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 12:17:55 -0500 Subject: [PATCH 100/146] [MeterSelectionMap] Add cluster & increase cache time to improve preformance --- frontend/package-lock.json | 27 ++ frontend/package.json | 1 + frontend/src/service/ApiServiceNew.ts | 12 +- .../MeterSelection/MeterSelectionMap.tsx | 254 ++++++++++-------- 4 files changed, 175 insertions(+), 119 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a6596cea..a7518105 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", @@ -195,6 +196,23 @@ "node": ">=6.9.0" } }, + "node_modules/@changey/react-leaflet-markercluster": { + "version": "4.0.0-rc1", + "resolved": "https://registry.npmjs.org/@changey/react-leaflet-markercluster/-/react-leaflet-markercluster-4.0.0-rc1.tgz", + "integrity": "sha512-gS1lEQiQwyeI6Y6Wuxuqqffwywm7giQw4tbcqtJP8zyT5bc3AzW2/EVJGwWORYo/PLDdDnvOrpI+lUJy2UA5MQ==", + "license": "MIT", + "dependencies": { + "@react-leaflet/core": "^2.0.0", + "leaflet": "^1.8.0", + "leaflet.markercluster": "^1.5.3", + "react-leaflet": "^4.0.0" + }, + "peerDependencies": { + "leaflet": "^1.8.0", + "leaflet.markercluster": "^1.5.3", + "react-leaflet": "^4.0.0" + } + }, "node_modules/@choojs/findup": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", @@ -6278,6 +6296,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5f4b78a4..935b4f33 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "preview": "vite preview" }, "dependencies": { + "@changey/react-leaflet-markercluster": "^4.0.0-rc1", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@hookform/resolvers": "^3.2.0", diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index d8acc1a6..e075cd6d 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -237,15 +237,21 @@ export function useGetMeterLocations(searchstring: string | undefined) { const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery([route, searchstring], () => - GETFetch( + return useQuery({ + queryKey: [route, searchstring], + 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, + refetchOnMount: false, + refetchOnReconnect: false, + }); } export function useGetMeterTypeList() { diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index f6e11e46..3491c4a5 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -1,22 +1,22 @@ -import { useState, useEffect } from "react"; +import { useEffect } from "react"; import { useDebounce } from "use-debounce"; - import { - CircleMarker, MapContainer, TileLayer, Tooltip, GeoJSON, LayersControl, + Marker, Pane, } from "react-leaflet"; import { MeterMapDTO } from "../../../interfaces"; import L from "leaflet"; -import { useLeafletContext } from '@react-leaflet/core'; -import { FeatureCollection } from 'geojson'; +import { useLeafletContext } from "@react-leaflet/core"; +import { FeatureCollection } from "geojson"; import "leaflet/dist/leaflet.css"; +import "@changey/react-leaflet-markercluster/dist/styles.min.css"; import "../../../css/map.css"; import { useGetMeterLocations } from "../../../service/ApiServiceNew"; import * as tr_data from "../../../data/RoswellTR_v2.json"; @@ -24,8 +24,10 @@ import * as ss_data from "../../../data/RoswellSS.json"; import icon from "leaflet/dist/images/marker-icon.png"; import iconShadow from "leaflet/dist/images/marker-shadow.png"; -const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); +import { Box, Typography } from "@mui/material"; +import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; +const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); L.Marker.prototype.options.icon = DefaultIcon; interface MeterSelectionMapProps { @@ -46,7 +48,7 @@ const pm_colors: { [key: string]: string } = { "2028/2029": "blue", }; -// Map legend for PM colors +// Color legend component function ColorLegend() { const context = useLeafletContext(); @@ -54,21 +56,10 @@ function ColorLegend() { const legend = new L.Control({ position: "bottomleft" }); legend.onAdd = function() { const div = L.DomUtil.create("div", "info legend"); - const seasons = Object.keys(pm_colors); - - // Add title to legend div.innerHTML = "

PM Season

"; - - // loop through PM seasons and generate a label with a colored square for each interval - for (var i = 0; i < seasons.length; i++) { - div.innerHTML += - ' ' + - seasons[i] + - "
"; + for (const season in pm_colors) { + div.innerHTML += ` ${season}
`; } - return div; }; @@ -78,18 +69,14 @@ function ColorLegend() { return () => { container.removeControl(legend); }; - }); + }, [context.map]); return null; } -// Function for getting color from last PM which is based on year and month +// Function for getting color from last PM function getMeterColor(last_pm: string) { - // The string has the format "YYYY-MM-DDTHH:MM:SSZ" Use month and year to determine color - //Convert string to a date object const last_pm_date = new Date(last_pm); - - // Test if the date is in or after July if (last_pm_date.getMonth() >= 7) { return pm_colors[ last_pm_date.getFullYear() + "/" + (last_pm_date.getFullYear() + 1) @@ -101,7 +88,7 @@ function getMeterColor(last_pm: string) { } } -//Specify the type of the trss_data +// Static geojson data const trData: FeatureCollection = tr_data as FeatureCollection; const ssData: FeatureCollection = ss_data as FeatureCollection; @@ -110,99 +97,134 @@ export default function MeterSelectionMap({ meterSearch, }: MeterSelectionMapProps) { const [meterSearchDebounced] = useDebounce(meterSearch, 250); - const [meterMarkersMap, setMeterMarkersMap] = useState([]); - const mapStyle = { height: "100%", width: "100%" }; const meterMarkers = useGetMeterLocations(meterSearchDebounced); - - useEffect(() => { - setMeterMarkersMap( - meterMarkers.data?.map((meter: MeterMapDTO) => ( - onMeterSelection(meter.id), - }} - > - {meter.serial_number} - - )) ?? [] - ); - }, [meterMarkers.data]); + const mapStyle = { height: "100%", width: "100%" }; return ( - - - {/* Base Layers */} - - - - - - - - - {/* Overlays */} - - - {meterMarkersMap} - - - - - - ({ - color: "red", - dashArray: "5, 10", - weight: 2, - fillOpacity: 0, - })} + <> + + + {/* Base Layers */} + + - - - - - - ({ - color: "black", - weight: 3, - fillOpacity: 0, - })} - onEachFeature={(feature, layer) => { - if (feature.properties?.TWNSHPLAB) { - layer.bindTooltip(feature.properties.TWNSHPLAB, { - permanent: true, - direction: "center", - className: "geojson-label", - }); - } - }} + + + + - - - - - + + + {/* Markers Cluster Overlay */} + + { + const count = cluster.getChildCount(); + + return L.divIcon({ + html: `
${count}
`, + className: "", + iconSize: [40, 40], + }); + }} + > + {meterMarkers.isSuccess && + meterMarkers.data.map((meter: MeterMapDTO) => { + const color = meter.last_pm ? getMeterColor(meter.last_pm) : "black"; + + return ( + onMeterSelection(meter.id), + }} + icon={L.divIcon({ + className: "", + html: `
`, + })} + > + {meter.serial_number} +
+ ); + })} +
+
+ + {/* Section GeoJSON */} + + + ({ + color: "red", + dashArray: "5, 10", + weight: 2, + fillOpacity: 0, + })} + /> + + + + {/* Township/Range GeoJSON */} + + + ({ + color: "black", + weight: 3, + fillOpacity: 0, + })} + onEachFeature={(feature, layer) => { + if (feature.properties?.TWNSHPLAB) { + layer.bindTooltip(feature.properties.TWNSHPLAB, { + permanent: true, + direction: "center", + className: "geojson-label", + }); + } + }} + /> + + + + + + + + {/* Loading and empty states */} + {meterMarkers.isLoading && ( + + Loading meter markers... + + )} + + {meterMarkers.isSuccess && meterMarkers.data.length === 0 && ( + + + No meters found for that search. + + + )} + ); } From 9bea8075b86c4746210a4dc1132c030653cae27a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 13:02:42 -0500 Subject: [PATCH 101/146] [routes/meters] Optimized /meters_locations endpoint --- api/routes/meters.py | 94 +++++++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/api/routes/meters.py b/api/routes/meters.py index 445299b3..c94357ea 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -172,56 +172,78 @@ def get_meters_locations( search_string: str = None, db: Session = Depends(get_db), ): - # Build the query statement based on query params - # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( - select(Meters).join(Wells, isouter=True).join(Locations, isouter=True) - ) - - # Ensure there are coordinates and meter is installed - query_statement = query_statement.where( - and_( - Locations.latitude.is_not(None), - Locations.longitude.is_not(None), - Meters.status_id == 1, + select( + Meters.id, + Meters.serial_number, + Wells.id.label("well_id"), + Wells.ra_number, + Wells.name, + Locations.id.label("location_id"), + Locations.latitude, + Locations.longitude, + Locations.trss, + ) + .select_from(Meters) + .join(Wells, Meters.well_id == Wells.id, isouter=True) + .join(Locations, Wells.location_id == Locations.id, isouter=True) + .where( + and_( + Locations.latitude.is_not(None), + Locations.longitude.is_not(None), + Meters.status_id == 1, # Only installed meters + ) ) ) if search_string: + ilike_term = f"%{search_string}%" query_statement = query_statement.where( or_( - Meters.serial_number.ilike(f"%{search_string}%"), - Wells.ra_number.ilike(f"%{search_string}%"), - Locations.trss.ilike(f"%{search_string}%"), + Meters.serial_number.ilike(ilike_term), + Wells.ra_number.ilike(ilike_term), + Locations.trss.ilike(ilike_term), ) ) - - meters = db.scalars(query_statement).all() - meter_ids = [meter.id for meter in meters] - - # Get the date of the last PM for each meter - pm_query = text('select max(timestamp_start) ' - 'as last_pm, meter_id from "MeterActivities" ' - 'where activity_type_id=4 and meter_id = ANY(:mids) ' - 'group by meter_id') - - pm_years = db.execute(pm_query,{'mids':meter_ids}).fetchall() - # Create a dictionary of meter_id to last_pm - pm_dict = {pm[1]: pm[0] for pm in pm_years} + result = db.execute(query_statement).fetchall() + meter_ids = [row.id for row in result] + + if not meter_ids: + return [] # Short-circuit if nothing matched + + # Query latest PMs for those meters + pm_query = text( + """ + SELECT MAX(timestamp_start) AS last_pm, meter_id + FROM "MeterActivities" + WHERE activity_type_id = 4 + AND meter_id = ANY(:mids) + GROUP BY meter_id + """ + ) + pm_years = db.execute(pm_query, {"mids": meter_ids}).fetchall() + pm_dict = {row.meter_id: row.last_pm for row in pm_years} - # Create a list of MeterMapDTO objects + # Map to DTOs manually for added performance meter_map_list = [] - for meter in meters: - # Find the last PM year for the meter - last_pm = pm_dict.get(meter.id, None) + for row in result: meter_map_list.append( meter_schemas.MeterMapDTO( - id=meter.id, - serial_number=meter.serial_number, - well=meter.well, - location=meter.well.location, - last_pm=last_pm + id=row.id, + serial_number=row.serial_number, + well={ + "id": row.well_id, + "ra_number": row.ra_number, + "name": row.name, + }, + location={ + "id": row.location_id, + "latitude": row.latitude, + "longitude": row.longitude, + "trss": row.trss, + }, + last_pm=pm_dict.get(row.id) ) ) From 0bc462c248d366fc5a393a26ffb27da9de1776cf Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 13:09:09 -0500 Subject: [PATCH 102/146] [MeterSelectionMap] Add ts-ingore flag for untyped module --- frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 3491c4a5..664cf41a 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -25,6 +25,8 @@ import * as ss_data from "../../../data/RoswellSS.json"; import icon from "leaflet/dist/images/marker-icon.png"; import iconShadow from "leaflet/dist/images/marker-shadow.png"; import { Box, Typography } from "@mui/material"; + +// @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); From 092092852644e89cdac322ec96e929ad3b85b8e0 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 15:00:54 -0700 Subject: [PATCH 103/146] [LICENSE] Add Apache v2.0 License --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 6e00427cd88108b87c44e59480472c71545bb914 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 15:01:41 -0700 Subject: [PATCH 104/146] Delete LICENSE.md --- LICENSE.md | 201 ----------------------------------------------------- 1 file changed, 201 deletions(-) delete mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md deleted file mode 100644 index 261eeb9e..00000000 --- a/LICENSE.md +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - 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 ebfb01add292d56ca2ec68bfc6983017bdc63e7c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 14:53:10 -0700 Subject: [PATCH 105/146] [README] Update sections & layout --- README.md | 98 ++++++++++++++----------------------------------------- 1 file changed, 24 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index fdd50663..35bacfb3 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,31 @@ -# WaterManagerDB +# Meter Manager -[![Unittests](https://github.com/NMWDI/WaterManagerDB/actions/workflows/testing.yml/badge.svg)](https://github.com/NMWDI/WaterManagerDB/actions/workflows/testing.yml) -[![Format code](https://github.com/NMWDI/WaterManagerDB/actions/workflows/format_code.yml/badge.svg)](https://github.com/NMWDI/WaterManagerDB/actions/workflows/format_code.yml) +### Purpose -## Versions -- V0.2.0 - Added weasyprint for PDF generation, this requires a new Docker image to be built. -- V0.1.52 - Deploy chlorides for admin testing -- V0.1.51.1 - Increased frontend signout to 300 minutes -- 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.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 -- V0.1.40 - Add register to UI on meter details -- V0.1.39 - Default share ose when workorder, OSE access to register information -- V0.1.38 - Change logout time to 8 hours, show work order count in navigation -- V0.1.37.1 - Fix various work order bugs -- V0.1.37 - Update OSE API to include ose_request_id and new endpoint -- V0.1.36 - Improved work orders, testing still needed -- V0.1.35.1 - Fix bug with well search failing on certain inputs -- V0.1.35 - Update continuous data stream IDs for monitoring wells -- V0.1.34 - Work orders ready for alpha testing, reordered monitoring wells -- V0.1.33 - Add Meter Status Filter to Meters Table -- V0.1.32 - Fix Monitoring Wells so that table updates after change -- V0.1.31 - Added note "verified register ratio" and made it appear by default -- V0.1.30 - Admin can edit monitoring well data (note that monitoring well table still not updating automatically) -- V0.1.29 - Fixed bug preventing meter type change -- V0.1.28 - Full admin UI on meter page -- V0.1.27 - Give admin ability to add out of order activities, fix zoom on map, other minor changes -- V0.1.26 - Add functional merge button for admin -- V0.1.25 - Fix datesort on meter history, give techs limited well management -- V0.1.24 - Add non-functional merge button for initial testing -- V0.1.23 - Prevent duplicate activities from being added -- V0.1.22 - Change ownership so there is now water_users and meter_owner -- V0.1.21 - Implement Degrees Minutes Seconds (DMS) for lat/long -- V0.1.20 - Fix monitoring wells sort -- V0.1.19 - Updated OSE endpoint to have activity_id, reorganized data returned -- V0.1.18 - Only require well on install activity, display OSE tag -- V0.1.17 - Restructure security code to prevent database connection problems -- V0.1.16 - Fixed bug where status is changed when clearing well from meter -- V0.1.15 - Updated backend to use SQLAlchemy 2 (resolve connection issue?) -- V0.1.14 - Display RA number instead of well name, well distance is now observation, new default observations -- V0.1.13 - Add checkbox for sharing activities with OSE. -- V0.1.12 - Change lat/long to DMS, reorder observation inputs, block out of order activities -- V0.1.11 - Remove all async code to see if it fixes deadlock issue -- V0.1.10 - Fix owners and osetag on Wells page -- V0.1.9 - Add owners to Meters table, fix various bugs -- V0.1.8 - Fix bug in meter selection autocomplete -- V0.1.7 - Fixed bugs in Add Meter -- V0.1.6 - Various fixes and meter search via map UI -- V0.1.5 - Various minor bug fixes -- V0.1.4 - Updated "current installation" section of activities to match Meters page -- V0.1.3 - Added user admin, improved appearance, fixed OSE endpoint scope. -- V0.1.2 - Added an initial parts inventory and minor meter installation UI improvements -- V0.1.1 - Initial version with new clean database -- V0.0 - Initial minimum viable product +**Meter Manager** is a web application designed to help **PVACD** manage its water data. It provides tools for spatial visualization, maintenance tracking, and regulatory reporting. -## Purpose -This web app facilitates reporting of water management operations to other organizations. The initial goal is to help water conservation districts communicate with local and state governments. However, the interface may eventually be developed to be more general. +--- -## Installation -The app is built with the following components: -* PostgreSQL database with PostGIS extension -* Python FastAPI backend for interfacing with database -* React based frontend +### Features -App components are organized into Docker containers, but it can also be run locally. +- 🗺️ Interactive map UI for meters and wells +- 🔧 Meter activities, maintenance history, and preventive maintenance (PM) tracking +- 📦 Inventory and part usage tracking +- 📋 Work order and technician assignment system +- 📑 OSE-compliant reporting endpoint +- 🛠️ Admin features for editing, merging, and managing records +- 👥 Role-based access control (techs, admins, etc.) +- 🛰️ TRSS grid overlays for spatial reference +- 💧 Continuous monitoring support for observation wells -To run the app, clone the repository and use Docker Compose to run: -``` -/watermanagerdb >> docker compose -f docker-compose.dev.yml --build -``` +--- -The API component will need several environmental variables that should be specified in the file 'api/.env'. See api/.env_example for an example. The PostgreSQL environmental variables should match the database settings. +### Tech Stack + +| Layer | Technology | +|---------------|----------------------| +| **Frontend** | React + TypeScript | +| **Backend** | FastAPI (Python) | +| **Database** | PostgreSQL + PostGIS | +| **Container** | Docker Compose | +| **CI/CD** | GitHub Actions | From 03214b7fae2f5a82525d554abb5f917c9330af2c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 14:58:21 -0700 Subject: [PATCH 106/146] [CHANGELOG] init --- CHANGELOG.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ea05d4e8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +All notable changes to **Meter Manager** will be documented in this file. + +| Version | Changes | +|-----------|---------| +| v0.2.0 | Parts-used report functional with PDF download | +| v0.1.52 | Deploy chlorides for admin testing | +| v0.1.51.1 | Increased frontend signout to 300 minutes | +| 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.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 | +| v0.1.40 | Add register to UI on meter details | +| v0.1.39 | Default share ose when workorder, OSE access to register information | +| v0.1.38 | Change logout time to 8 hours, show work order count in navigation | +| v0.1.37.1 | Fix various work order bugs | +| v0.1.37 | Update OSE API to include ose_request_id and new endpoint | +| v0.1.36 | Improved work orders, testing still needed | +| v0.1.35.1 | Fix bug with well search failing on certain inputs | +| v0.1.35 | Update continuous data stream IDs for monitoring wells | +| v0.1.34 | Work orders ready for alpha testing, reordered monitoring wells | +| v0.1.33 | Add Meter Status Filter to Meters Table | +| v0.1.32 | Fix Monitoring Wells so that table updates after change | +| v0.1.31 | Added note "verified register ratio" and made it appear by default | +| v0.1.30 | Admin can edit monitoring well data (note that monitoring well table still not updating automatically) | +| v0.1.29 | Fixed bug preventing meter type change | +| v0.1.28 | Full admin UI on meter page | +| v0.1.27 | Give admin ability to add out of order activities, fix zoom on map, other minor changes | +| v0.1.26 | Add functional merge button for admin | +| v0.1.25 | Fix datesort on meter history, give techs limited well management | +| v0.1.24 | Add non-functional merge button for initial testing | +| v0.1.23 | Prevent duplicate activities from being added | +| v0.1.22 | Change ownership so there is now water_users and meter_owner | +| v0.1.21 | Implement Degrees Minutes Seconds (DMS) for lat/long | +| v0.1.20 | Fix monitoring wells sort | +| v0.1.19 | Updated OSE endpoint to have activity_id, reorganized data returned | +| v0.1.18 | Only require well on install activity, display OSE tag | +| v0.1.17 | Restructure security code to prevent database connection problems | +| v0.1.16 | Fixed bug where status is changed when clearing well from meter | +| v0.1.15 | Updated backend to use SQLAlchemy 2 (resolve connection issue?) | +| v0.1.14 | Display RA number instead of well name, well distance is now observation, new default observations | +| v0.1.13 | Add checkbox for sharing activities with OSE | +| v0.1.12 | Change lat/long to DMS, reorder observation inputs, block out of order activities | +| v0.1.11 | Remove all async code to see if it fixes deadlock issue | +| v0.1.10 | Fix owners and osetag on Wells page | +| v0.1.9 | Add owners to Meters table, fix various bugs | +| v0.1.8 | Fix bug in meter selection autocomplete | +| v0.1.7 | Fixed bugs in Add Meter | +| v0.1.6 | Various fixes and meter search via map UI | +| v0.1.5 | Various minor bug fixes | +| v0.1.4 | Updated "current installation" section of activities to match Meters page | +| v0.1.3 | Added user admin, improved appearance, fixed OSE endpoint scope | +| v0.1.2 | Added an initial parts inventory and minor meter installation UI improvements | +| v0.1.1 | Initial version with new clean database | +| v0.0.0 | Initial minimum viable product | From bf51804f41785be490b2d04e3e9555fb2f017b35 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 15 Aug 2025 17:36:21 -0500 Subject: [PATCH 107/146] [routes/wells] Update endpoint to accept optional chloride_group_id search param --- api/routes/wells.py | 11 +++++++++-- frontend/src/components/RegionMeasurementModals.tsx | 5 +++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/api/routes/wells.py b/api/routes/wells.py index e7f9ade2..01701e11 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -1,6 +1,6 @@ -from typing import List +from typing import List, Optional -from fastapi import Depends, APIRouter, HTTPException +from fastapi import Depends, APIRouter, HTTPException, Query from sqlalchemy import or_, select, desc, text from sqlalchemy.orm import Session, joinedload from sqlalchemy.exc import IntegrityError @@ -64,6 +64,7 @@ def get_wells( sort_by: WellSortByField = WellSortByField.Name, sort_direction: SortDirection = SortDirection.Ascending, has_chloride_group: bool = None, + chloride_group_id: Optional[str] = Query(None, pattern=r"^$|^\d+$"), db: Session = Depends(get_db), ): def sort_by_field_to_schema_field(name: WellSortByField): @@ -104,6 +105,12 @@ def sort_by_field_to_schema_field(name: WellSortByField): if has_chloride_group is not None: query_statement = query_statement.where(Wells.chloride_group_id.isnot(None)) + if chloride_group_id: + query_statement = query_statement.where( + Wells.chloride_group_id == int(chloride_group_id) + ) + + if sort_by: schema_field_name = sort_by_field_to_schema_field(sort_by) diff --git a/frontend/src/components/RegionMeasurementModals.tsx b/frontend/src/components/RegionMeasurementModals.tsx index 3d975088..d420818c 100644 --- a/frontend/src/components/RegionMeasurementModals.tsx +++ b/frontend/src/components/RegionMeasurementModals.tsx @@ -120,7 +120,7 @@ export const NewMeasurementModal = ({ } }; -const WellSelection = ({ region_id }: { region_id: number }) => { + const WellSelection = ({ region_id }: { region_id: number }) => { return ( Well @@ -255,7 +255,7 @@ export const UpdateMeasurementModal = ({ Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups"], + queryKey: ["wells", "has_chloride_groups", region_id], queryFn: () => fetchWithAuth({ method: "GET", @@ -264,6 +264,7 @@ export const UpdateMeasurementModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, + chloride_group_id: region_id, limit: 100, }, }), From 1b3079e40794b91df12a7ef444e53692084c22da Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 18 Aug 2025 11:08:42 -0500 Subject: [PATCH 108/146] [WorkOrdersTable] Add Location Name & Water Users columns --- api/routes/activities.py | 93 ++++++++++--------- .../src/views/WorkOrders/WorkOrdersTable.tsx | 22 ++++- 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/api/routes/activities.py b/api/routes/activities.py index 7b09661b..068063d8 100644 --- a/api/routes/activities.py +++ b/api/routes/activities.py @@ -28,7 +28,6 @@ from api.session import get_db from api.security import get_current_user from api.enums import ScopedUser, WorkOrderStatus -from api.route_util import _patch activity_router = APIRouter() @@ -123,7 +122,7 @@ def post_activity( try: db.add(meter_activity) db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException( status_code=409, detail="Activity overlaps with existing activity." ) @@ -466,63 +465,73 @@ def get_service_types(db: Session = Depends(get_db)): def get_note_types(db: Session = Depends(get_db)): return db.scalars(select(NoteTypeLU)).all() -# Get work orders endpoint @activity_router.get( "/work_orders", dependencies=[Depends(ScopedUser.Read)], - response_model=List[meter_schemas.WorkOrder], tags=["Work Orders"], ) def get_work_orders( - filter_by_status: list[WorkOrderStatus] = Query('Open'), + filter_by_status: list[WorkOrderStatus] = Query(['Open']), start_date: datetime = Query(datetime.strptime('2024-06-01', '%Y-%m-%d')), - db: Session = Depends(get_db) - ): + db: Session = Depends(get_db), +): query_stmt = ( select(workOrders) .options( joinedload(workOrders.status), joinedload(workOrders.meter), - joinedload(workOrders.assigned_user) + joinedload(workOrders.assigned_user), ) .join(workOrderStatusLU) .where(workOrderStatusLU.name.in_(filter_by_status)) .where(workOrders.date_created >= start_date) ) - work_orders: list[workOrders] = db.scalars(query_stmt).all() + work_orders = db.scalars(query_stmt).all() - # I was unable to get associated_activities to work with joinedload, so I'm doing it manually here - relevant_activities = db.scalars(select(MeterActivities).where(MeterActivities.work_order_id.in_([wo.id for wo in work_orders]))).all() - - # Create a dictionary where the key is the work order ID and the value is a list of associated activities - associated_activities = {} - for activity in relevant_activities: - if activity.work_order_id in associated_activities: - associated_activities[activity.work_order_id].append(activity) - else: - associated_activities[activity.work_order_id] = [activity] - - # Create a WorkOrder schema for each work order returned - output_work_orders = [] - for wo in work_orders: - work_order_schema = meter_schemas.WorkOrder( - work_order_id = wo.id, - ose_request_id=wo.ose_request_id, - date_created = wo.date_created, - creator = wo.creator, - meter_id = wo.meter.id, - meter_serial = wo.meter.serial_number, - title = wo.title, - description = wo.description, - status = wo.status.name, - notes = wo.notes, - assigned_user_id = wo.assigned_user_id, - assigned_user= wo.assigned_user.username if wo.assigned_user else None, - associated_activities=associated_activities[wo.id] if wo.id in associated_activities else [] - ) - output_work_orders.append(work_order_schema) + # grab activities separately + relevant_activities = db.scalars( + select(MeterActivities) + .options(joinedload(MeterActivities.location)) + .where(MeterActivities.work_order_id.in_([wo.id for wo in work_orders])) + ).all() - return output_work_orders + # group activities by work_order_id + activities_by_wo = {} + for act in relevant_activities: + activities_by_wo.setdefault(act.work_order_id, []).append({ + "id": act.id, + "timestamp_start": act.timestamp_start, + "timestamp_end": act.timestamp_end, + "description": act.description, + "submitting_user_id": act.submitting_user_id, + "meter_id": act.meter_id, + "activity_type_id": act.activity_type_id, + "location_id": act.location_id, + "location_name": act.location.name if act.location else None, + "ose_share": act.ose_share, + "water_users": act.water_users, + }) + + # build output + output = [] + for wo in work_orders: + output.append({ + "work_order_id": wo.id, + "ose_request_id": wo.ose_request_id, + "date_created": wo.date_created, + "creator": wo.creator, + "meter_id": wo.meter.id, + "meter_serial": wo.meter.serial_number, + "title": wo.title, + "description": wo.description, + "status": wo.status.name, + "notes": wo.notes, + "assigned_user_id": wo.assigned_user_id, + "assigned_user": wo.assigned_user.username if wo.assigned_user else None, + "associated_activities": activities_by_wo.get(wo.id, []), + }) + + return output # Create work order endpoint @activity_router.post( @@ -564,7 +573,7 @@ def create_work_order(new_work_order: meter_schemas.CreateWorkOrder, db: Session try: db.add(work_order) db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException( status_code=409, detail="Title empty or already exists for this meter." @@ -661,7 +670,7 @@ def patch_work_order(patch_work_order_form: meter_schemas.PatchWorkOrder, user: # Database should block empty title and non-unique (date, title, meter_id) combinations try: db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException( status_code=409, detail="Title already exists for this meter." diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index b4750e2e..fe2ed589 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -365,11 +365,31 @@ export default function WorkOrdersTable() { field: "assigned_user_id", headerName: "Technician Assigned", width: 200, - valueGetter: (id) => getUserFromID(id as number), + valueGetter: (id: number) => getUserFromID(id), type: "singleSelect", valueOptions: userList.data?.map((user) => user.full_name) ?? [], editable: hasAdminScope, }, + { + field: "location_name", + headerName: "Location Name", + width: 200, + renderCell: (params) => { + const activities = params.row.associated_activities ?? []; + return activities.length > 0 ? activities[0].location_name : ""; + }, + }, + { + field: "water_users", + headerName: "Water Users", + width: 200, + renderCell: (params) => { + const activities = params.row.associated_activities ?? []; + return activities.length > 0 && activities[0].water_users + ? activities[0].water_users + : ""; + }, + }, { field: "actions", headerName: "Actions", From 0e7f1a2c40c38bc4475563847ea7006bd857d5b4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 18 Aug 2025 11:28:11 -0500 Subject: [PATCH 109/146] [RegionMeasurementModals] Patch get region id on new measurement model --- frontend/src/components/RegionMeasurementModals.tsx | 3 ++- frontend/src/views/Reports/Chlorides/index.tsx | 13 ++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/RegionMeasurementModals.tsx b/frontend/src/components/RegionMeasurementModals.tsx index d420818c..4df3f6f2 100644 --- a/frontend/src/components/RegionMeasurementModals.tsx +++ b/frontend/src/components/RegionMeasurementModals.tsx @@ -51,7 +51,7 @@ export const NewMeasurementModal = ({ Error, MonitoredWell[] >({ - queryKey: ["wells", "has_chloride_groups"], + queryKey: ["wells", "has_chloride_groups", region_id], queryFn: () => fetchWithAuth({ method: "GET", @@ -60,6 +60,7 @@ export const NewMeasurementModal = ({ sort_by: "ra_number", sort_direction: "asc", has_chloride_group: true, + chloride_group_id: region_id, limit: 100, }, }), diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 9c9c066d..e6f4b1a3 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -4,7 +4,6 @@ import { Button, Card, CardContent, - CardHeader, Grid, IconButton, Tooltip, @@ -15,6 +14,7 @@ import { useForm } from "react-hook-form"; import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import dayjs from "dayjs"; +import { CustomCardHeader } from "../../../components/CustomCardHeader"; const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), @@ -43,14 +43,9 @@ export const ChloridesReportView = () => { }} > - - Chlorides Report - - - } - sx={{ mb: 0, pb: 0 }} + From ec478b6839471bed9329ee96867e1bc83826b246 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 18 Aug 2025 13:38:23 -0500 Subject: [PATCH 110/146] [MeasurementModal] Patch broken timestamps recall from form to backend req --- api/routes/well_measurements.py | 8 +++++--- api/routes/wells.py | 4 ++-- frontend/src/components/NewMeasurementModal.tsx | 17 ++++++++++------- .../src/components/RegionMeasurementModals.tsx | 17 ++++++++++------- .../src/components/WellMeasurementModals.tsx | 17 ++++++++++------- frontend/src/interfaces.d.ts | 4 ++-- .../views/WellManagement/WellDetailsCard.tsx | 2 -- 7 files changed, 39 insertions(+), 30 deletions(-) diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index d886528e..b7eacbdf 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -74,8 +74,8 @@ def add_waterlevel( ) def read_waterlevels( well_ids: List[int] = Query(..., description="One or more well IDs"), - from_month: Optional[str] = Query(None, pattern=r"^\d{4}-\d{2}$"), - to_month: Optional[str] = Query(None, pattern=r"^\d{4}-\d{2}$"), + from_month: Optional[str] = Query(None, pattern=r"^$|^\d{4}-\d{2}$"), + to_month: Optional[str] = Query(None, pattern=r"^$|^\d{4}-\d{2}$"), isAveragingAllWells: bool = Query(False), isComparingTo1970Average: bool = Query(False), comparisonYear: Optional[str] = Query(None, pattern=r"^$|^\d{4}$"), @@ -162,7 +162,9 @@ def add_year_average(year: int, label: str): if not well_ids and not isComparingTo1970Average and not comparisonYear: return [] - group_by = "month" if (to_date - from_date).days >= 365 else "day" + group_by = None + if from_month and to_month: + group_by = "month" if (to_date - from_date).days >= 365 else "day" response_data = [] diff --git a/api/routes/wells.py b/api/routes/wells.py index 01701e11..2d7707bc 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -170,7 +170,7 @@ def update_well( try: db.add(well_to_patch) db.commit() - except IntegrityError as e: + except IntegrityError as _e: raise HTTPException(status_code=409, detail="RA number already exists") # Get updated model with relationships @@ -221,7 +221,7 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g db.commit() db.refresh(new_well_model) - except IntegrityError as e: + except IntegrityError as _e: db.rollback() db.delete(new_location_model) db.commit() diff --git a/frontend/src/components/NewMeasurementModal.tsx b/frontend/src/components/NewMeasurementModal.tsx index 4a9c17eb..5378ec7b 100644 --- a/frontend/src/components/NewMeasurementModal.tsx +++ b/frontend/src/components/NewMeasurementModal.tsx @@ -44,15 +44,18 @@ export function NewMeasurementModal({ // Sends user entered information to the parent through callback function onMeasurementSubmitted() { - const d = new Date( - Date.parse(date?.format() ?? Date()), - ).toLocaleDateString(); - const t = new Date( - Date.parse(time?.format() ?? Date()), - ).toLocaleTimeString(); + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); handleSubmitNewMeasurement({ - timestamp: new Date(Date.parse(d + " " + t)), + timestamp: combinedDateTime.toISOString(), value: value as number, submitting_user_id: selectedUserID as number, well_id: -1, // Set by parent diff --git a/frontend/src/components/RegionMeasurementModals.tsx b/frontend/src/components/RegionMeasurementModals.tsx index 4df3f6f2..b85afb29 100644 --- a/frontend/src/components/RegionMeasurementModals.tsx +++ b/frontend/src/components/RegionMeasurementModals.tsx @@ -76,17 +76,20 @@ export const NewMeasurementModal = ({ const [time, setTime] = useState(dayjs.utc()); function onMeasurementSubmitted() { - const d = new Date( - Date.parse(date?.format() ?? Date()), - ).toLocaleDateString(); - const t = new Date( - Date.parse(time?.format() ?? Date()), - ).toLocaleTimeString(); + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); handleSubmitNewMeasurement({ region_id: 0, // Set by parent well_id: selectedWellID as number, - timestamp: new Date(Date.parse(d + " " + t)), + timestamp: combinedDateTime.toISOString(), value: value as number, submitting_user_id: selectedUserID as number, }); diff --git a/frontend/src/components/WellMeasurementModals.tsx b/frontend/src/components/WellMeasurementModals.tsx index e98f4a12..e8440226 100644 --- a/frontend/src/components/WellMeasurementModals.tsx +++ b/frontend/src/components/WellMeasurementModals.tsx @@ -48,15 +48,18 @@ export function NewMeasurementModal({ // Sends user entered information to the parent through callback function onMeasurementSubmitted() { - const d = new Date( - Date.parse(date?.format() ?? Date()), - ).toLocaleDateString(); - const t = new Date( - Date.parse(time?.format() ?? Date()), - ).toLocaleTimeString(); + // default fallback: now + const selectedDate = date ?? dayjs(); + const selectedTime = time ?? dayjs(); + + // merge date + time into one object + const combinedDateTime = selectedDate + .hour(selectedTime.hour()) + .minute(selectedTime.minute()) + .second(selectedTime.second()); handleSubmitNewMeasurement({ - timestamp: new Date(Date.parse(d + " " + t)), + timestamp: combinedDateTime.toISOString(), value: value as number, submitting_user_id: selectedUserID as number, well_id: -1, // Set by parent diff --git a/frontend/src/interfaces.d.ts b/frontend/src/interfaces.d.ts index 7b63b713..69224b1f 100644 --- a/frontend/src/interfaces.d.ts +++ b/frontend/src/interfaces.d.ts @@ -555,7 +555,7 @@ export interface ST2Response { // The object that gets sent to the backend to add a new measurement export interface NewWellMeasurement { well_id: number - timestamp: Date + timestamp: string value: number submitting_user_id: number } @@ -569,7 +569,7 @@ export interface PatchWellMeasurement { export interface NewRegionMeasurement { region_id: number - timestamp: Date + timestamp: string value: number submitting_user_id: number well_id: number diff --git a/frontend/src/views/WellManagement/WellDetailsCard.tsx b/frontend/src/views/WellManagement/WellDetailsCard.tsx index 5cc9111f..e64c8708 100644 --- a/frontend/src/views/WellManagement/WellDetailsCard.tsx +++ b/frontend/src/views/WellManagement/WellDetailsCard.tsx @@ -210,8 +210,6 @@ export const WellDetailsCard = ({ label="Region ID" type="number" inputProps={{ min: 1, max: 128 }} - //error={errors?.chloride_group_id?.message != undefined} - //helperText={errors?.chloride_group_id?.message} />
From bbf061a4db68fa488ee964af3d7217b48b287305 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 22 Aug 2025 09:53:39 -0500 Subject: [PATCH 111/146] [Reports/Chlorides] init prep work for Chlorides report --- .../src/views/Reports/Chlorides/index.tsx | 85 +++++++++++++++---- frontend/src/views/Reports/index.tsx | 1 - 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index e6f4b1a3..48bf8122 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,6 +1,8 @@ import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; +import { useMutation } from "react-query"; +import dayjs, { Dayjs } from "dayjs"; +import { useAuthHeader } from "react-auth-kit"; import { - Box, Button, Card, CardContent, @@ -9,11 +11,12 @@ import { 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"; +import { BackgroundBox } from "../../../components/BackgroundBox"; +import { API_URL } from "../../../config"; +import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; import { CustomCardHeader } from "../../../components/CustomCardHeader"; const schema = yup.object().shape({ @@ -27,28 +30,72 @@ const defaultSchema = { }; export const ChloridesReportView = () => { - const { control, reset } = useForm({ + const { control, reset, watch } = useForm({ resolver: yupResolver(schema), defaultValues: defaultSchema, }); + const from = watch("from"); + const to = watch("to"); + + const authHeader = useAuthHeader(); + const downloadPDFMutation = useMutation({ + mutationFn: async ({ + from, + to, + }: { + from: Dayjs; + to: Dayjs; + }) => { + const params = new URLSearchParams({ + from_month: from.format("YYYY-MM"), + to_month: to.format("YYYY-MM"), + }); + + const response = await fetch( + `${API_URL}/chlorides/pdf?${params.toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("PDF generation failed"); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "parts_used_report.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const handleDownloadPDF = () => { + if (!from || !to) return; + + downloadPDFMutation.mutate({ + from, + to, + }); + }; + return ( - - + + - + @@ -60,7 +107,11 @@ export const ChloridesReportView = () => { - + @@ -105,6 +156,6 @@ export const ChloridesReportView = () => { - + ); }; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 92c760bb..6c9e4d07 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -47,7 +47,6 @@ export const ReportsView = () => { Icon={People} /> Date: Fri, 22 Aug 2025 11:56:15 -0500 Subject: [PATCH 112/146] [routes/chlorides] Refactor chlorides routes to have its own router --- api/main.py | 18 ++--- api/routes/chlorides.py | 123 ++++++++++++++++++++++++++++++++ api/routes/well_measurements.py | 102 -------------------------- 3 files changed, 133 insertions(+), 110 deletions(-) create mode 100644 api/routes/chlorides.py diff --git a/api/main.py b/api/main.py index cec8cf13..99ecf150 100644 --- a/api/main.py +++ b/api/main.py @@ -23,13 +23,14 @@ from api.schemas import security_schemas from api.models.main_models import Users -from api.routes.meters import meter_router -from api.routes.well_measurements import well_measurement_router from api.routes.activities import activity_router +from api.routes.admin import admin_router +from api.routes.chlorides import chlorides_router +from api.routes.maintenance import maintenance_router +from api.routes.meters import meter_router from api.routes.OSE import ose_router from api.routes.parts import part_router -from api.routes.maintenance import maintenance_router -from api.routes.admin import admin_router +from api.routes.well_measurements import well_measurement_router from api.routes.wells import well_router from api.security import ( @@ -127,12 +128,13 @@ def login_for_access_token( # ======================================= -authenticated_router.include_router(meter_router) authenticated_router.include_router(activity_router) -authenticated_router.include_router(well_measurement_router) -authenticated_router.include_router(part_router) -authenticated_router.include_router(maintenance_router) authenticated_router.include_router(admin_router) +authenticated_router.include_router(chlorides_router) +authenticated_router.include_router(maintenance_router) +authenticated_router.include_router(meter_router) +authenticated_router.include_router(part_router) +authenticated_router.include_router(well_measurement_router) authenticated_router.include_router(well_router) add_pagination(app) diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py new file mode 100644 index 00000000..b5d5a297 --- /dev/null +++ b/api/routes/chlorides.py @@ -0,0 +1,123 @@ + +from typing import List + +from fastapi import Depends, APIRouter, Query +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import select, and_ + + +from api.schemas import well_schemas +from api.models.main_models import WellMeasurements, Wells +from api.session import get_db +from api.enums import ScopedUser + +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +import matplotlib +matplotlib.use("Agg") # Force non-GUI backend + +TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates" + +templates = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "xml"]) +) + +chlorides_router = APIRouter() + +@chlorides_router.get( + "/chlorides", + dependencies=[Depends(ScopedUser.Read)], + response_model=List[well_schemas.WellMeasurementDTO], + tags=["Chlorides"], +) +def read_chlorides( + chloride_group_id: int = Query(..., description="Chloride group ID to filter by"), + db: Session = Depends(get_db) +): + chloride_concentration_group_id = 5 + + return db.scalars( + select(WellMeasurements) + .options( + joinedload(WellMeasurements.submitting_user), + joinedload(WellMeasurements.well) + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .where( + and_( + WellMeasurements.observed_property_id == chloride_concentration_group_id, + Wells.chloride_group_id == chloride_group_id + ) + ) + ).all() + + +@chlorides_router.post( + "/chlorides", + dependencies=[Depends(ScopedUser.WellMeasurementWrite)], + response_model=well_schemas.ChlorideMeasurement, + tags=["Chlorides"], +) +def add_chloride_measurement( + chloride_measurement: well_schemas.WellMeasurement, + db: Session = Depends(get_db), +): + # Create a new chloride measurement as a WellMeasurement + well_measurement = WellMeasurements( + timestamp = chloride_measurement.timestamp, + value = chloride_measurement.value, + observed_property_id = 5, # Chloride Concentration + submitting_user_id = chloride_measurement.submitting_user_id, + unit_id = chloride_measurement.unit_id, + well_id = chloride_measurement.well_id + ) + + db.add(well_measurement) + db.commit() + + return well_measurement + +@chlorides_router.patch( + "/chlorides", + dependencies=[Depends(ScopedUser.WellMeasurementWrite)], + response_model=well_schemas.WellMeasurement, + tags=["Chlorides"], +) +def patch_chloride_measurement( + chloride_measurement_patch: well_schemas.PatchChlorideMeasurement, + db: Session = Depends(get_db), +): + # Find the measurement + well_measurement = ( + db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_patch.id)).first() + ) + + # Update the fields, all are mandatory + well_measurement.submitting_user_id = chloride_measurement_patch.submitting_user_id + well_measurement.timestamp = chloride_measurement_patch.timestamp + well_measurement.value = chloride_measurement_patch.value + well_measurement.unit_id = chloride_measurement_patch.unit_id + well_measurement.well_id = chloride_measurement_patch.well_id + + db.commit() + + return well_measurement + +@chlorides_router.delete( + "/chlorides", + dependencies=[Depends(ScopedUser.Admin)], + tags=["Chlorides"], +) +def delete_chloride_measurement(chloride_measurement_id: int, db: Session = Depends(get_db)): + # Find the measurement + well_measurement = ( + db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_id)).first() + ) + + db.delete(well_measurement) + db.commit() + + return True + diff --git a/api/routes/well_measurements.py b/api/routes/well_measurements.py index b7eacbdf..10baa025 100644 --- a/api/routes/well_measurements.py +++ b/api/routes/well_measurements.py @@ -466,105 +466,3 @@ def delete_waterlevel(waterlevel_id: int, db: Session = Depends(get_db)): db.commit() return True - - -# ----------------- Chloride Concentration ----------------- # - - -@well_measurement_router.get( - "/chlorides", - dependencies=[Depends(ScopedUser.Read)], - response_model=List[well_schemas.WellMeasurementDTO], - tags=["Chlorides"], -) -def read_chlorides( - chloride_group_id: int = Query(..., description="Chloride group ID to filter by"), - db: Session = Depends(get_db) -): - chloride_concentration_group_id = 5 - - return db.scalars( - select(WellMeasurements) - .options( - joinedload(WellMeasurements.submitting_user), - joinedload(WellMeasurements.well) - ) - .join(Wells, Wells.id == WellMeasurements.well_id) - .where( - and_( - WellMeasurements.observed_property_id == chloride_concentration_group_id, - Wells.chloride_group_id == chloride_group_id - ) - ) - ).all() - - -@well_measurement_router.post( - "/chlorides", - dependencies=[Depends(ScopedUser.WellMeasurementWrite)], - response_model=well_schemas.ChlorideMeasurement, - tags=["Chlorides"], -) -def add_chloride_measurement( - chloride_measurement: well_schemas.WellMeasurement, - db: Session = Depends(get_db), -): - # Create a new chloride measurement as a WellMeasurement - well_measurement = WellMeasurements( - timestamp = chloride_measurement.timestamp, - value = chloride_measurement.value, - observed_property_id = 5, # Chloride Concentration - submitting_user_id = chloride_measurement.submitting_user_id, - unit_id = chloride_measurement.unit_id, - well_id = chloride_measurement.well_id - ) - - db.add(well_measurement) - db.commit() - - return well_measurement - -@well_measurement_router.patch( - "/chlorides", - dependencies=[Depends(ScopedUser.WellMeasurementWrite)], - response_model=well_schemas.WellMeasurement, - tags=["Chlorides"], -) -def patch_chloride_measurement( - chloride_measurement_patch: well_schemas.PatchChlorideMeasurement, - db: Session = Depends(get_db), -): - # Find the measurement - well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_patch.id)).first() - ) - - # Update the fields, all are mandatory - well_measurement.submitting_user_id = chloride_measurement_patch.submitting_user_id - well_measurement.timestamp = chloride_measurement_patch.timestamp - well_measurement.value = chloride_measurement_patch.value - well_measurement.unit_id = chloride_measurement_patch.unit_id - well_measurement.well_id = chloride_measurement_patch.well_id - - db.commit() - - return well_measurement - -@well_measurement_router.delete( - "/chlorides", - dependencies=[Depends(ScopedUser.Admin)], - tags=["Chlorides"], -) -def delete_chloride_measurement(chloride_measurement_id: int, db: Session = Depends(get_db)): - # Find the measurement - well_measurement = ( - db.scalars(select(WellMeasurements).where(WellMeasurements.id == chloride_measurement_id)).first() - ) - - db.delete(well_measurement) - db.commit() - - return True - - - From d2fad86f6d3dc7a4103e10e323404ab84e0daa24 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 25 Aug 2025 19:06:13 -0500 Subject: [PATCH 113/146] [Reports/Chlorides] Add map & route & display cards for report --- api/routes/chlorides.py | 169 +++++++++++++- frontend/src/components/DirectionCard.tsx | 30 +++ frontend/src/components/RHControlled/index.ts | 20 ++ frontend/src/components/StatCell.tsx | 13 ++ frontend/src/components/index.ts | 19 ++ frontend/src/utils/NumberDataFormatter.ts | 5 + frontend/src/utils/index.ts | 1 + .../Meters/MeterSelection/MeterSelection.tsx | 10 +- .../MeterSelection/MeterSelectionMap.tsx | 208 ++++++++++-------- .../src/views/Reports/Chlorides/index.tsx | 176 ++++++++++++++- 10 files changed, 537 insertions(+), 114 deletions(-) create mode 100644 frontend/src/components/DirectionCard.tsx create mode 100644 frontend/src/components/RHControlled/index.ts create mode 100644 frontend/src/components/StatCell.tsx create mode 100644 frontend/src/components/index.ts create mode 100644 frontend/src/utils/NumberDataFormatter.ts diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index b5d5a297..6ca24bb1 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -1,13 +1,14 @@ +from typing import Optional, List +from datetime import datetime +import calendar -from typing import List - -from fastapi import Depends, APIRouter, Query +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import and_, select from sqlalchemy.orm import Session, joinedload -from sqlalchemy import select, and_ - from api.schemas import well_schemas -from api.models.main_models import WellMeasurements, Wells +from api.models.main_models import WellMeasurements, Wells, Locations from api.session import get_db from api.enums import ScopedUser @@ -54,6 +55,114 @@ def read_chlorides( ).all() +class MinMaxAvg(BaseModel): + min: Optional[float] = None + max: Optional[float] = None + avg: Optional[float] = None + + +class ChlorideReportNums(BaseModel): + north: MinMaxAvg + south: MinMaxAvg + east: MinMaxAvg + west: MinMaxAvg + + +@chlorides_router.get( + "/chlorides/report", + dependencies=[Depends(ScopedUser.Read)], + response_model=ChlorideReportNums, + tags=["Chlorides"], +) +def get_chlorides_report( + from_month: Optional[str] = Query( + None, + description="Month start, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + to_month: Optional[str] = Query( + None, + description="Month end, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + db: Session = Depends(get_db), +): + """ + Returns min/max/avg for north/south/east/west halves **within the SE quadrant of New Mexico**, + over the specified [from_month, to_month] inclusive range, for chloride wells in the given group. + """ + + CHLORIDE_OBSERVED_PROPERTY_ID = 5 + + # Parse months + start_dt = _parse_month(from_month) if from_month else None + end_dt = _parse_month(to_month) if to_month else None + if start_dt and not end_dt: + end_dt = start_dt + if end_dt: + end_dt = _month_end(end_dt) + + stmt = ( + select( + WellMeasurements.value, + Locations.latitude, + Locations.longitude, + ) + .join(Wells, Wells.id == WellMeasurements.well_id) + .join(Locations, Locations.id == Wells.location_id) + .where( + and_( + WellMeasurements.observed_property_id == CHLORIDE_OBSERVED_PROPERTY_ID, + Locations.latitude.is_not(None), + Locations.longitude.is_not(None), + # Restrict to NM bbox first + Locations.latitude >= NM_LAT_MIN, + Locations.latitude <= NM_LAT_MAX, + Locations.longitude >= NM_LON_MIN, + Locations.longitude <= NM_LON_MAX, + # Time range (optional) + *( [WellMeasurements.timestamp >= start_dt] if start_dt else [] ), + *( [WellMeasurements.timestamp <= end_dt] if end_dt else [] ), + ) + ) + ) + + rows = db.execute(stmt).all() + + se_rows = [ + (val, lat, lon) + for (val, lat, lon) in rows + if (lat is not None and lon is not None + and SE_MIN_LAT <= float(lat) <= SE_MAX_LAT + and SE_MIN_LON <= float(lon) <= SE_MAX_LON) + ] + + north_vals: List[float] = [] + south_vals: List[float] = [] + east_vals: List[float] = [] + west_vals: List[float] = [] + + for val, lat, lon in se_rows: + # North vs South halves within the SE quadrant + if float(lat) >= SE_MID_LAT: + north_vals.append(float(val)) + else: + south_vals.append(float(val)) + + # East vs West halves within the SE quadrant + if float(lon) >= SE_MID_LON: + east_vals.append(float(val)) + else: + west_vals.append(float(val)) + + return ChlorideReportNums( + north=_stats(north_vals), + south=_stats(south_vals), + east=_stats(east_vals), + west=_stats(west_vals), + ) + + @chlorides_router.post( "/chlorides", dependencies=[Depends(ScopedUser.WellMeasurementWrite)], @@ -121,3 +230,51 @@ def delete_chloride_measurement(chloride_measurement_id: int, db: Session = Depe return True + +def _parse_month(m: Optional[str]) -> Optional[datetime]: + """ + Accepts 'YYYY-MM' or 'YYYY MM'. Returns the first day of month at 00:00:00. + """ + if not m: + return None + m = m.strip() + # Try 'YYYY-MM' + for fmt in ("%Y-%m", "%Y %m"): + try: + dt = datetime.strptime(m, fmt) + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + except ValueError: + continue + raise HTTPException(status_code=400, detail="Invalid month format. Use 'YYYY-MM' or 'YYYY MM'.") + +def _month_end(dt: datetime) -> datetime: + last_day = calendar.monthrange(dt.year, dt.month)[1] + return dt.replace(day=last_day, hour=23, minute=59, second=59, microsecond=999999) + +def _stats(values: List[float]) -> MinMaxAvg: + if not values: + return MinMaxAvg() + return MinMaxAvg( + min=min(values), + max=max(values), + avg=(sum(values) / len(values)) + ) + +# Approx NM bounding box (degrees) +NM_LAT_MIN = 31.3325 +NM_LAT_MAX = 37.0000 +NM_LON_MIN = -109.0500 +NM_LON_MAX = -103.0000 + +# Precompute midlines for quadrants +NM_MID_LAT = (NM_LAT_MIN + NM_LAT_MAX) / 2.0 +NM_MID_LON = (NM_LON_MIN + NM_LON_MAX) / 2.0 + +# Southeast quadrant bounds +SE_MIN_LAT = NM_LAT_MIN +SE_MAX_LAT = NM_MID_LAT +SE_MIN_LON = NM_MID_LON +SE_MAX_LON = NM_LON_MAX + +SE_MID_LAT = (SE_MIN_LAT + SE_MAX_LAT) / 2.0 +SE_MID_LON = (SE_MIN_LON + SE_MAX_LON) / 2.0 diff --git a/frontend/src/components/DirectionCard.tsx b/frontend/src/components/DirectionCard.tsx new file mode 100644 index 00000000..c046f287 --- /dev/null +++ b/frontend/src/components/DirectionCard.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, Divider, Stack, Typography } from '@mui/material'; +import { StatCell } from './StatCell' + +export const DirectionCard = ({ + title, + min, + avg, + max, +}: { + title: string; + min?: number; + avg?: number; + max?: number; +}) => { + return ( + + + + {title} + + + + + + + + + + ); +} diff --git a/frontend/src/components/RHControlled/index.ts b/frontend/src/components/RHControlled/index.ts new file mode 100644 index 00000000..718157aa --- /dev/null +++ b/frontend/src/components/RHControlled/index.ts @@ -0,0 +1,20 @@ +export * from './ControlledActivitySelect' +export * from './ControlledAutocomplete' +export * from './ControlledCheckbox' +export * from './ControlledDatepicker' +export * from './ControlledDMS' +export * from './ControlledMeterRegisterSelect' +export * from './ControlledMeterSelection' +export * from './ControlledMeterStatusTypeSelect' +export * from './ControlledMeterTypeSelect' +export * from './ControlledPartTypeSelect' +export * from './ControlledSelect' +export * from './ControlledTextbox' +export * from './ControlledTimepicker' +export * from './ControlledUserSelect' +export * from './ControlledWellSelection' +export * from './ControlledWorkOrderSelect' +export * from './NotesChipSelect' +export * from './NSPChipSelect' +export * from './PartsChipSelect' +export * from './ServicesChipSelect' diff --git a/frontend/src/components/StatCell.tsx b/frontend/src/components/StatCell.tsx new file mode 100644 index 00000000..3fd8f43a --- /dev/null +++ b/frontend/src/components/StatCell.tsx @@ -0,0 +1,13 @@ +import { Stack, Typography } from "@mui/material"; +import { formatNumberData } from "../utils"; + +export const StatCell = ({ label, value }: { label: string; value?: number }) => { + return ( + + + {label} + + {formatNumberData(value)} ppm + + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 00000000..fcf6a432 --- /dev/null +++ b/frontend/src/components/index.ts @@ -0,0 +1,19 @@ +export * from './BackgroundBox' +export * from './TristateToggle' +export * from './ChipSelect' +export * from './MergeWellModal' +export * from './RegionMeasurementModals' +export * from './UserSelection' +export * from './CustomCardHeader' +export * from './MeterRegisterSelect' +export * from './RHControlled' +export * from './DirectionCard' +export * from './MeterSelection' +export * from './StatCell' +export * from './WellSelection' +export * from './MeterTypeSelect' +export * from './TabPanel' +export * from './WorkOrderSelect' +export * from './GridFooterWithButton' +export * from './NavLink' +export * from './Topbar' diff --git a/frontend/src/utils/NumberDataFormatter.ts b/frontend/src/utils/NumberDataFormatter.ts new file mode 100644 index 00000000..8ca84380 --- /dev/null +++ b/frontend/src/utils/NumberDataFormatter.ts @@ -0,0 +1,5 @@ +// Small formatter so numbers look nice (e.g., 1,234.57) and undefined shows "—" +export const formatNumberData = (n?: number) => + typeof n === "number" + ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(n) + : "—"; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 43021bbd..2b7d0b72 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./DateUtils" export * from "./HttpUtils" export * from "./MonitoredWellsUtils" +export * from "./NumberDataFormatter" diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index f56f92a2..a81b3728 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -129,10 +129,12 @@ export const MeterSelection = ({ - + + + diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 664cf41a..e51bbb43 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -100,38 +100,51 @@ export default function MeterSelectionMap({ }: MeterSelectionMapProps) { const [meterSearchDebounced] = useDebounce(meterSearch, 250); const meterMarkers = useGetMeterLocations(meterSearchDebounced); - const mapStyle = { height: "100%", width: "100%" }; return ( <> - - - {/* Base Layers */} - - - - - - - - - {/* Markers Cluster Overlay */} - - { - const count = cluster.getChildCount(); - - return L.divIcon({ - html: `
+ {/* Base Layers */} + + + + + + + + + {/* Markers Cluster Overlay */} + + { + const count = cluster.getChildCount(); + + return L.divIcon({ + html: `
${count}
`, - className: "", - iconSize: [40, 40], - }); - }} - > - {meterMarkers.isSuccess && - meterMarkers.data.map((meter: MeterMapDTO) => { - const color = meter.last_pm ? getMeterColor(meter.last_pm) : "black"; - - return ( - onMeterSelection(meter.id), - }} - icon={L.divIcon({ - className: "", - html: `
`, - })} - > - {meter.serial_number} -
- ); - })} -
-
- - {/* Section GeoJSON */} - - - ({ - color: "red", - dashArray: "5, 10", - weight: 2, - fillOpacity: 0, - })} - /> - - - - {/* Township/Range GeoJSON */} - - - ({ - color: "black", - weight: 3, - fillOpacity: 0, - })} - onEachFeature={(feature, layer) => { - if (feature.properties?.TWNSHPLAB) { - layer.bindTooltip(feature.properties.TWNSHPLAB, { - permanent: true, - direction: "center", - className: "geojson-label", - }); - } + className: "", + iconSize: [40, 40], + }); }} - /> - - - - - - + > + {meterMarkers.isSuccess && + meterMarkers.data.map((meter: MeterMapDTO) => { + const color = meter.last_pm ? getMeterColor(meter.last_pm) : "black"; + + return ( + onMeterSelection(meter.id), + }} + icon={L.divIcon({ + className: "", + html: `
`, + })} + > + {meter.serial_number} +
+ ); + })} + + + + {/* Section GeoJSON */} + + + ({ + color: "red", + dashArray: "5, 10", + weight: 2, + fillOpacity: 0, + })} + /> + + + + {/* Township/Range GeoJSON */} + + + ({ + color: "black", + weight: 3, + fillOpacity: 0, + })} + onEachFeature={(feature, layer) => { + if (feature.properties?.TWNSHPLAB) { + layer.bindTooltip(feature.properties.TWNSHPLAB, { + permanent: true, + direction: "center", + className: "geojson-label", + }); + } + }} + /> + + + + + + + {/* Loading and empty states */} {meterMarkers.isLoading && ( diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 48bf8122..8c0440ef 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,5 +1,5 @@ import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; -import { useMutation } from "react-query"; +import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; import { useAuthHeader } from "react-auth-kit"; import { @@ -9,19 +9,44 @@ import { Grid, IconButton, Tooltip, + Typography, + Alert, + Skeleton, + Stack, + Divider, + Box, } from "@mui/material"; +import { + MapContainer, + TileLayer, + // Tooltip, + // GeoJSON, + LayersControl, + // Marker, + // Pane, +} 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 { BackgroundBox } from "../../../components/BackgroundBox"; import { API_URL } from "../../../config"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { CustomCardHeader } from "../../../components/CustomCardHeader"; +import { CustomCardHeader, BackgroundBox, DirectionCard } from "../../../components"; +import { useFetchWithAuth } from "../../../hooks"; + +import "leaflet/dist/leaflet.css"; +import "@changey/react-leaflet-markercluster/dist/styles.min.css"; const schema = yup.object().shape({ - from: yup.mixed().nullable().required("From date is required"), - to: yup.mixed().nullable().required("To date is required"), + from: yup.mixed().nullable().required("From date is required"), + 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)); + }), }); const defaultSchema = { @@ -29,6 +54,21 @@ const defaultSchema = { to: dayjs(), }; +interface iMinMaxAvg { + min?: number; + max?: number; + avg?: number; +} + +interface iChlorideReportNums { + north: iMinMaxAvg; + south: iMinMaxAvg; + east: iMinMaxAvg; + west: iMinMaxAvg; +} + +const mapStyle = { height: "100%", width: "100%" }; + export const ChloridesReportView = () => { const { control, reset, watch } = useForm({ resolver: yupResolver(schema), @@ -39,6 +79,24 @@ export const ChloridesReportView = () => { const to = watch("to"); const authHeader = useAuthHeader(); + const fetchWithAuth = useFetchWithAuth(); + + const chloridesQuery = useQuery({ + queryKey: ["Chlorides", "Reports", from, to], + queryFn: async () => { + const searchParams = new URLSearchParams({ + from_month: from?.format("YYYY-MM"), + to_month: to?.format("YYYY-MM"), + }); + + return fetchWithAuth({ + method: "GET", + route: `/chlorides/report?${searchParams.toString()}`, + }) + }, + enabled: !!from && !!to, + }); + const downloadPDFMutation = useMutation({ mutationFn: async ({ from, @@ -130,6 +188,7 @@ export const ChloridesReportView = () => { label="From" sx={{ minWidth: "15rem" }} control={control} + size="medium" name="from" views={["year", "month"]} openTo="year" @@ -141,6 +200,7 @@ export const ChloridesReportView = () => { label="To" sx={{ minWidth: "15rem" }} control={control} + size="medium" name="to" views={["year", "month"]} openTo="year" @@ -148,7 +208,109 @@ export const ChloridesReportView = () => { /> - + + + Chloride Reading: + {chloridesQuery.isLoading && ( + + {[0, 1, 2, 3].map((i) => ( + + + + + + + + + + + + + + ))} + + )} + {chloridesQuery.isError && ( + + {chloridesQuery.error?.message || "Failed to load chloride readings."} + + )} + {!chloridesQuery.isLoading && !chloridesQuery.isError && ( + + + + + + + + + + + + + + + )} + + + + + + {/* Base Layers */} + + + + + + + + + + + + @@ -156,6 +318,6 @@ export const ChloridesReportView = () => { - + ); }; From 97b434d1a0a655c537e1fcd762d3991a98d073c7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 25 Aug 2025 19:25:08 -0500 Subject: [PATCH 114/146] [SoutheastGuideLayer] Add new layer for as a visual aid --- .../src/components/SoutheastGuideLayer.tsx | 93 +++++++++++++++++++ frontend/src/components/index.ts | 1 + .../src/views/Reports/Chlorides/index.tsx | 5 +- 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/SoutheastGuideLayer.tsx diff --git a/frontend/src/components/SoutheastGuideLayer.tsx b/frontend/src/components/SoutheastGuideLayer.tsx new file mode 100644 index 00000000..62d3eead --- /dev/null +++ b/frontend/src/components/SoutheastGuideLayer.tsx @@ -0,0 +1,93 @@ +// SoutheastGuideLayer.tsx +import * as L from "leaflet"; +import { LayersControl, Pane, FeatureGroup, Rectangle, Polyline, Marker, Tooltip } from "react-leaflet"; + +const NM_LAT_MIN = 31.3325; +const NM_LAT_MAX = 37.0; +const NM_LON_MIN = -109.05; +const NM_LON_MAX = -103.0; + +const MID_LAT = (NM_LAT_MIN + NM_LAT_MAX) / 2; +const MID_LON = (NM_LON_MIN + NM_LON_MAX) / 2; + +// Southeast quadrant bounds +const SE_LAT_MIN = NM_LAT_MIN; +const SE_LAT_MAX = MID_LAT; +const SE_LON_MIN = MID_LON; +const SE_LON_MAX = NM_LON_MAX; + +const SE_MID_LAT = (SE_LAT_MIN + SE_LAT_MAX) / 2; +const SE_MID_LON = (SE_LON_MIN + SE_LON_MAX) / 2; + +// Helpers +const rectBounds: [[number, number], [number, number]] = [ + [SE_LAT_MIN, SE_LON_MIN], + [SE_LAT_MAX, SE_LON_MAX], +]; + +const horizLine = [ + [SE_MID_LAT, SE_LON_MIN], + [SE_MID_LAT, SE_LON_MAX], +] as [number, number][]; + +const vertLine = [ + [SE_LAT_MIN, SE_MID_LON], + [SE_LAT_MAX, SE_MID_LON], +] as [number, number][]; + +const labelIcon = (text: string) => + L.divIcon({ + className: "", + html: `
${text}
`, + }); + +export const SoutheastGuideLayer = () => + ( + + {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} + + + {/* SE quadrant rectangle */} + + + {/* Midlines */} + + + + {/* Labels (placed toward the center of each half) */} + + + + + + {/* Optional: center dot where lines cross */} + {/*
' })} /> */} + + +
+ ); diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index fcf6a432..c44c0221 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -13,6 +13,7 @@ export * from './StatCell' export * from './WellSelection' export * from './MeterTypeSelect' export * from './TabPanel' +export * from './SoutheastGuideLayer' export * from './WorkOrderSelect' export * from './GridFooterWithButton' export * from './NavLink' diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 8c0440ef..ad6451fb 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -31,7 +31,7 @@ import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { API_URL } from "../../../config"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { CustomCardHeader, BackgroundBox, DirectionCard } from "../../../components"; +import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer } from "../../../components"; import { useFetchWithAuth } from "../../../hooks"; import "leaflet/dist/leaflet.css"; @@ -67,8 +67,6 @@ interface iChlorideReportNums { west: iMinMaxAvg; } -const mapStyle = { height: "100%", width: "100%" }; - export const ChloridesReportView = () => { const { control, reset, watch } = useForm({ resolver: yupResolver(schema), @@ -306,6 +304,7 @@ export const ChloridesReportView = () => { attribution="© OpenStreetMap contributors" /> +
From e265e0aa0539777b36a04af8b7e68103fdd0b93f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Mon, 25 Aug 2025 20:55:34 -0500 Subject: [PATCH 115/146] [SoutheastGuideLayer] Fix import error --- .../src/components/SoutheastGuideLayer.tsx | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/SoutheastGuideLayer.tsx b/frontend/src/components/SoutheastGuideLayer.tsx index 62d3eead..7faaa9a2 100644 --- a/frontend/src/components/SoutheastGuideLayer.tsx +++ b/frontend/src/components/SoutheastGuideLayer.tsx @@ -1,6 +1,6 @@ // SoutheastGuideLayer.tsx import * as L from "leaflet"; -import { LayersControl, Pane, FeatureGroup, Rectangle, Polyline, Marker, Tooltip } from "react-leaflet"; +import { LayersControl, Pane, FeatureGroup, Rectangle, Polyline, Marker } from "react-leaflet"; const NM_LAT_MIN = 31.3325; const NM_LAT_MAX = 37.0; @@ -53,41 +53,41 @@ const labelIcon = (text: string) => }); export const SoutheastGuideLayer = () => - ( - - {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} - - - {/* SE quadrant rectangle */} - +( + + {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} + + + {/* SE quadrant rectangle */} + - {/* Midlines */} - - + {/* Midlines */} + + - {/* Labels (placed toward the center of each half) */} - - - - + {/* Labels (placed toward the center of each half) */} + + + + - {/* Optional: center dot where lines cross */} - {/* ' })} /> */} - - - - ); + {/* Optional: center dot where lines cross */} + {/* ' })} /> */} + + + +); From 31eae0ece058f4156943ecc112b3ac5865dbf518 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 26 Aug 2025 11:26:52 -0500 Subject: [PATCH 116/146] [components/Layers] Refactor layers to their own components --- .../components/Layers/OpenStreetMapLayer.tsx | 10 ++++++++++ .../src/components/Layers/SatelliteLayer.tsx | 10 ++++++++++ .../{ => Layers}/SoutheastGuideLayer.tsx | 0 frontend/src/components/Layers/index.ts | 4 ++++ frontend/src/components/index.ts | 2 +- .../Meters/MeterSelection/MeterSelectionMap.tsx | 16 +++------------- frontend/src/views/Reports/Chlorides/index.tsx | 17 +++-------------- 7 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/Layers/OpenStreetMapLayer.tsx create mode 100644 frontend/src/components/Layers/SatelliteLayer.tsx rename frontend/src/components/{ => Layers}/SoutheastGuideLayer.tsx (100%) create mode 100644 frontend/src/components/Layers/index.ts diff --git a/frontend/src/components/Layers/OpenStreetMapLayer.tsx b/frontend/src/components/Layers/OpenStreetMapLayer.tsx new file mode 100644 index 00000000..d6dfaf91 --- /dev/null +++ b/frontend/src/components/Layers/OpenStreetMapLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet" + +export const OpenStreetMapLayer = () => ( + + + +) diff --git a/frontend/src/components/Layers/SatelliteLayer.tsx b/frontend/src/components/Layers/SatelliteLayer.tsx new file mode 100644 index 00000000..d22221ca --- /dev/null +++ b/frontend/src/components/Layers/SatelliteLayer.tsx @@ -0,0 +1,10 @@ +import { LayersControl, TileLayer } from "react-leaflet" + +export const SatelliteLayer = () => ( + + + +) diff --git a/frontend/src/components/SoutheastGuideLayer.tsx b/frontend/src/components/Layers/SoutheastGuideLayer.tsx similarity index 100% rename from frontend/src/components/SoutheastGuideLayer.tsx rename to frontend/src/components/Layers/SoutheastGuideLayer.tsx diff --git a/frontend/src/components/Layers/index.ts b/frontend/src/components/Layers/index.ts new file mode 100644 index 00000000..a4be50d8 --- /dev/null +++ b/frontend/src/components/Layers/index.ts @@ -0,0 +1,4 @@ +export * from './SoutheastGuideLayer' +export * from './SatelliteLayer' +export * from './OpenStreetMapLayer' + diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index c44c0221..588432f8 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -13,7 +13,7 @@ export * from './StatCell' export * from './WellSelection' export * from './MeterTypeSelect' export * from './TabPanel' -export * from './SoutheastGuideLayer' +export * from './Layers' export * from './WorkOrderSelect' export * from './GridFooterWithButton' export * from './NavLink' diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index e51bbb43..2eac0bdb 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -28,6 +28,7 @@ import { Box, Typography } from "@mui/material"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; +import { OpenStreetMapLayer, SatelliteLayer } from "../../../components"; const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); L.Marker.prototype.options.icon = DefaultIcon; @@ -120,19 +121,8 @@ export default function MeterSelectionMap({ > {/* Base Layers */} - - - - - - - + + {/* Markers Cluster Overlay */} diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index ad6451fb..9508fa5d 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -31,7 +31,7 @@ import * as yup from "yup"; import { yupResolver } from "@hookform/resolvers/yup"; import { API_URL } from "../../../config"; import ControlledDatepicker from "../../../components/RHControlled/ControlledDatepicker"; -import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer } from "../../../components"; +import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer, SatelliteLayer, OpenStreetMapLayer } from "../../../components"; import { useFetchWithAuth } from "../../../hooks"; import "leaflet/dist/leaflet.css"; @@ -291,19 +291,8 @@ export const ChloridesReportView = () => { > {/* Base Layers */} - - - - - - - + + From 9fa924d69d01cb0d7ea6ec94556c76f0564f8b23 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 26 Aug 2025 12:10:00 -0500 Subject: [PATCH 117/146] [MeterSelectionMap] Add Satellite & SE Guide Layers; plus improved caching --- frontend/src/service/ApiServiceNew.ts | 14 +- .../MeterSelection/MeterSelectionMap.tsx | 11 +- .../src/views/Reports/Chlorides/index.tsx | 3 - .../WellManagement/WellManagementView.tsx | 2 +- .../views/WellManagement/WellSelectionMap.tsx | 145 ++++++++++++------ .../src/views/WellManagement/WellsTable.tsx | 18 +-- 6 files changed, 119 insertions(+), 74 deletions(-) diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index e075cd6d..31bcc15c 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -423,25 +423,29 @@ export function useGetWells(params: WellListQueryParams | undefined) { ); } -// Start Get Well List for Map View export function useGetWellLocations(searchstring: string | undefined) { const route = "well_locations"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - return useQuery, Error>([route, searchstring], () => - GETFetch( + return useQuery, Error>({ + queryKey: [route, searchstring], + 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, + refetchOnMount: false, + refetchOnReconnect: false, + }); } -// End export function useGetWell(params: WellDetailsQueryParams | undefined) { const route = "well"; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 2eac0bdb..558b7662 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -2,7 +2,6 @@ import { useEffect } from "react"; import { useDebounce } from "use-debounce"; import { MapContainer, - TileLayer, Tooltip, GeoJSON, LayersControl, @@ -33,11 +32,6 @@ import { OpenStreetMapLayer, SatelliteLayer } from "../../../components"; const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); L.Marker.prototype.options.icon = DefaultIcon; -interface MeterSelectionMapProps { - meterSearch: string; - onMeterSelection: Function; -} - // Define marker colors which are based on the year of the last PM (July - June) const pm_colors: { [key: string]: string } = { "2020/2021": "brown", @@ -98,7 +92,10 @@ const ssData: FeatureCollection = ss_data as FeatureCollection; export default function MeterSelectionMap({ onMeterSelection, meterSearch, -}: MeterSelectionMapProps) { +}: { + meterSearch: string; + onMeterSelection: Function; +}) { const [meterSearchDebounced] = useDebounce(meterSearch, 250); const meterMarkers = useGetMeterLocations(meterSearchDebounced); diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 9508fa5d..c889a5f9 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -18,9 +18,6 @@ import { } from "@mui/material"; import { MapContainer, - TileLayer, - // Tooltip, - // GeoJSON, LayersControl, // Marker, // Pane, diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index 87d62c1e..4c44a38f 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -15,7 +15,7 @@ export default function WellManagementView() { return ( - + ([]); + const wellMarkers: any = useGetWellLocations(wellSearchDebounced); - const mapStyle = { - height: "500px", - }; + return ( + <> + + + + {/* Base Layers */} + + + - const wellMarkers: any = useGetWellLocations(wellSearchDebounced); - const onClickMarker = (well: Well) => { - setSelectedWell(well); - }; + {/* Wells Cluster Overlay */} + + { + const count = cluster.getChildCount(); + return L.divIcon({ + html: `
${count}
`, + className: "", + iconSize: [40, 40], + }); + }} + > + {wellMarkers.isSuccess && + wellMarkers.data.map((well: Well) => ( + setSelectedWell(well), + }} + > + + {well.name || well.ra_number || well.id} + + + ))} +
+
+
+
+
- useEffect(() => { - setwellMarkersMap( - wellMarkers.data?.map((well: Well) => { - return ( - { - onClickMarker(well); - }, - }} - > - ); - }), - ); - }, [wellMarkers.data]); + {/* Loading and empty states */} + {wellMarkers.isLoading && ( + + Loading well markers... + + )} - return ( - - - {wellMarkersMap} - + {wellMarkers.isSuccess && wellMarkers.data.length === 0 && ( + + + No wells found for that search. + + + )} + ); } diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index a79c08da..a6d1dd2a 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -15,28 +15,25 @@ import WellSelectionTable from "./WellSelectionTable"; import WellSelectionMap from "./WellSelectionMap"; import { CustomCardHeader } from "../../components/CustomCardHeader"; -interface WellsTableProps { - setSelectedWell: Function; - setWellAddMode: Function; -} - export const WellsTable = ({ setSelectedWell, setWellAddMode, -}: WellsTableProps) => { +}: { + setSelectedWell: Function; + setWellAddMode: Function; +}) => { const [wellSearchQuery, setWellSearchQuery] = useState(""); - const [currentTabIndex, setCurrentTabIndex] = useState(0); const handleTabChange = (_: React.SyntheticEvent, newTabIndex: number) => setCurrentTabIndex(newTabIndex); return ( - + - + @@ -60,7 +57,7 @@ export const WellsTable = ({ /> - + - Date: Tue, 26 Aug 2025 13:12:17 -0500 Subject: [PATCH 118/146] [routes/wells] Add infiniteQuery to well map --- api/routes/chlorides.py | 42 +++++++++++++- api/routes/wells.py | 58 ++++--------------- frontend/src/service/ApiServiceNew.ts | 40 ++++++------- .../src/views/Reports/Chlorides/index.tsx | 10 ++-- .../views/WellManagement/WellSelectionMap.tsx | 38 +++++++++--- 5 files changed, 102 insertions(+), 86 deletions(-) diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index 6ca24bb1..f9c1bfbe 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -8,9 +8,9 @@ from sqlalchemy.orm import Session, joinedload from api.schemas import well_schemas -from api.models.main_models import WellMeasurements, Wells, Locations +from api.models.main_models import WellMeasurements, Wells, Locations, WellUseLU from api.session import get_db -from api.enums import ScopedUser +from api.enums import ScopedUser, SortDirection from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -55,6 +55,44 @@ def read_chlorides( ).all() +@chlorides_router.get( + "/chloride_groups", + dependencies=[Depends(ScopedUser.Read)], + response_model=List[well_schemas.ChlorideGroupResponse], + tags=["Chlorides"], +) +def get_chloride_groups( + sort_direction: SortDirection = SortDirection.Ascending, + db: Session = Depends(get_db), +): + query = ( + select(Wells) + .options(joinedload(Wells.location), joinedload(Wells.use_type)) + .join(Locations, isouter=True) + .join(WellUseLU, isouter=True) + .where(Wells.chloride_group_id.isnot(None)) + ) + + if sort_direction == SortDirection.Ascending: + query = query.order_by(Wells.chloride_group_id.asc()) + else: + query = query.order_by(Wells.chloride_group_id.desc()) + + wells = db.scalars(query).all() + + groups = {} + for well in wells: + group_id = well.chloride_group_id + if group_id not in groups: + groups[group_id] = [] + if well.ra_number: + groups[group_id].append(well.ra_number) + + return [ + {"id": group_id, "names": sorted(names)} + for group_id, names in groups.items() + ] + class MinMaxAvg(BaseModel): min: Optional[float] = None max: Optional[float] = None diff --git a/api/routes/wells.py b/api/routes/wells.py index 2d7707bc..fb2d0cd1 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -237,10 +237,6 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g return new_well_model - -# Get List of well for MapView -# Get search for well similar to /well but no pagination and only for installed well -# Returns all installed well with a location when search is None @well_router.get( "/well_locations", dependencies=[Depends(ScopedUser.Read)], @@ -249,13 +245,19 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g ) def get_wells_locations( search_string: str = None, + limit: int = 500, + offset: int = 0, db: Session = Depends(get_db), ): - # Build the query statement based on query params - # joinedload loads relationships, outer joins on relationship tables makes them search/sortable query_statement = ( select(Wells) - .options(joinedload(Wells.location), joinedload(Wells.use_type)) + .options( + joinedload(Wells.location), + joinedload(Wells.use_type), + ) + .where( + Wells.location_id.isnot(None) + ) ) if search_string: @@ -268,12 +270,9 @@ def get_wells_locations( ) ) - - return db.scalars(query_statement).all() + return db.scalars(query_statement.offset(offset).limit(limit)).all() -# End - @well_router.get( "/well", dependencies=[Depends(ScopedUser.Read)], @@ -347,40 +346,3 @@ def merge_well(well: well_schemas.SubmitWellMerge, db: Session = Depends(get_db) return True -@well_router.get( - "/chloride_groups", - dependencies=[Depends(ScopedUser.Read)], - response_model=List[well_schemas.ChlorideGroupResponse], - tags=["Chlorides"], -) -def get_chloride_groups( - sort_direction: SortDirection = SortDirection.Ascending, - db: Session = Depends(get_db), -): - query = ( - select(Wells) - .options(joinedload(Wells.location), joinedload(Wells.use_type)) - .join(Locations, isouter=True) - .join(WellUseLU, isouter=True) - .where(Wells.chloride_group_id.isnot(None)) - ) - - if sort_direction == SortDirection.Ascending: - query = query.order_by(Wells.chloride_group_id.asc()) - else: - query = query.order_by(Wells.chloride_group_id.desc()) - - wells = db.scalars(query).all() - - groups = {} - for well in wells: - group_id = well.chloride_group_id - if group_id not in groups: - groups[group_id] = [] - if well.ra_number: - groups[group_id].append(well.ra_number) - - return [ - {"id": group_id, "names": sorted(names)} - for group_id, names in groups.items() - ] diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 31bcc15c..7460f3b4 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "react-query"; import { useAuthHeader, useSignOut } from "react-auth-kit"; import { enqueueSnackbar, useSnackbar } from "notistack"; import { @@ -428,18 +428,26 @@ export function useGetWellLocations(searchstring: string | undefined) { const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); + const PAGE_SIZE = 500; - return useQuery, Error>({ + return useInfiniteQuery({ queryKey: [route, searchstring], - 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 + queryFn: async ({ pageParam = 0 }) => { + return GETFetch( + route, + { search_string: searchstring, offset: pageParam, limit: PAGE_SIZE }, + authHeader(), + signOut, + navigate + ); + }, + getNextPageParam: (lastPage, allPages) => { + // If we got less than PAGE_SIZE, we’re done + 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, refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, @@ -463,16 +471,6 @@ export function useGetWell(params: WellDetailsQueryParams | undefined) { ); } -// export function useGetWellLocations(searchstring: string | undefined) { -// const route = 'well_locations' -// const authHeader = useAuthHeader() -// const navigate = useNavigate() -// const signOut = useSignOut() - -// return useQuery, Error>([route, searchstring], () => -// GETFetch(route, {search_string: searchstring}, authHeader(), signOut, navigate), -// ) -// } export function useGetMeter(params: MeterDetailsQueryParams | undefined) { const route = "meter"; const authHeader = useAuthHeader(); diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index c889a5f9..2f666af2 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -19,8 +19,6 @@ import { import { MapContainer, LayersControl, - // Marker, - // Pane, } from "react-leaflet"; import { Link } from "react-router-dom"; import { useForm } from "react-hook-form"; @@ -237,7 +235,7 @@ export const ChloridesReportView = () => { )} {!chloridesQuery.isLoading && !chloridesQuery.isError && ( - + { max={chloridesQuery.data?.north?.max} /> - + { max={chloridesQuery.data?.south?.max} /> - + { max={chloridesQuery.data?.east?.max} /> - + { + if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { + wellQuery.fetchNextPage(); + } + }, [wellQuery.hasNextPage, wellQuery.isFetchingNextPage]); + + const wellMarkers = wellQuery.data?.pages.flat() ?? []; return ( <> @@ -80,8 +88,8 @@ export default function WellSelectionMap({ }); }} > - {wellMarkers.isSuccess && - wellMarkers.data.map((well: Well) => ( + {wellQuery.isSuccess && + wellMarkers.map((well: Well) => ( - - {/* Loading and empty states */} - {wellMarkers.isLoading && ( + {/* Loading first page */} + {wellQuery.isLoading && ( Loading well markers... )} - - {wellMarkers.isSuccess && wellMarkers.data.length === 0 && ( + {/* Loading additional pages */} + {wellQuery.isFetchingNextPage && ( + + Loading more wells... + + )} + {wellQuery.isSuccess && wellMarkers.length === 0 && ( No wells found for that search. )} + {/* Error */} + {wellQuery.isError && ( + + + Failed to load wells: {wellQuery.error.message} + + + )} ); } From afec946337c82bdbf623bd5a699ec39e65e82153 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 26 Aug 2025 13:25:47 -0500 Subject: [PATCH 119/146] [routes/chlorides] Add endpoint for pdf generation --- api/routes/chlorides.py | 50 ++++++++++++- api/templates/chlorides_report.html | 73 +++++++++++++++++++ .../src/views/Reports/Chlorides/index.tsx | 6 +- 3 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 api/templates/chlorides_report.html diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index f9c1bfbe..6c70128e 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -1,7 +1,9 @@ from typing import Optional, List from datetime import datetime import calendar - +from fastapi.responses import StreamingResponse +from weasyprint import HTML +from io import BytesIO from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import and_, select @@ -201,6 +203,52 @@ def get_chlorides_report( ) +@chlorides_router.get( + "/chlorides/report/pdf", + dependencies=[Depends(ScopedUser.Read)], + tags=["Chlorides"], +) +def download_chlorides_report_pdf( + from_month: Optional[str] = Query( + None, + description="Month start, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + to_month: Optional[str] = Query( + None, + description="Month end, 'YYYY-MM'", + pattern=r"^$|^\d{4}-\d{2}$", + ), + db: Session = Depends(get_db), +): + """ + Generate a PDF chloride report (north/south/east/west stats) + for the SE quadrant of New Mexico. + """ + # Re-use your existing logic by calling the data endpoint’s function + report = get_chlorides_report(from_month=from_month, to_month=to_month, db=db) + + # Render HTML using a template + template = templates.get_template("chlorides_report.html") + html_content = template.render( + report=report, + from_month=from_month, + to_month=to_month, + ) + + # Convert to PDF + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=chlorides_report.pdf" + }, + ) + @chlorides_router.post( "/chlorides", dependencies=[Depends(ScopedUser.WellMeasurementWrite)], diff --git a/api/templates/chlorides_report.html b/api/templates/chlorides_report.html new file mode 100644 index 00000000..1e15688e --- /dev/null +++ b/api/templates/chlorides_report.html @@ -0,0 +1,73 @@ + + + + + + + +

Chloride Report

+

+ From: {{ from_month or "All Data" }} +    + To: {{ to_month or "All Data" }} +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RegionMinMaxAverage
North{{ report.north.min }}{{ report.north.max }}{{ "%.2f"|format(report.north.avg or 0) }}
South{{ report.south.min }}{{ report.south.max }}{{ "%.2f"|format(report.south.avg or 0) }}
East{{ report.east.min }}{{ report.east.max }}{{ "%.2f"|format(report.east.avg or 0) }}
West{{ report.west.min }}{{ report.west.max }}{{ "%.2f"|format(report.west.avg or 0) }}
+ + + \ No newline at end of file diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 2f666af2..158e4c7c 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -104,7 +104,7 @@ export const ChloridesReportView = () => { }); const response = await fetch( - `${API_URL}/chlorides/pdf?${params.toString()}`, + `${API_URL}/chlorides/report/pdf?${params.toString()}`, { headers: { Authorization: authHeader() }, }, @@ -118,7 +118,7 @@ export const ChloridesReportView = () => { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = "parts_used_report.pdf"; + a.download = "chlorides_report.pdf"; a.click(); window.URL.revokeObjectURL(url); }, @@ -212,7 +212,7 @@ export const ChloridesReportView = () => { {chloridesQuery.isLoading && ( {[0, 1, 2, 3].map((i) => ( - + From 031af60425e2e0301b3b56bd8c3e3a28ae43fcab Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 26 Aug 2025 16:14:13 -0500 Subject: [PATCH 120/146] [Layers/SoutheastGuideLayer] Update layer based on chloride wells --- api/routes/chlorides.py | 8 +- api/routes/wells.py | 4 + .../src/assets/leaflet/marker-icon-red.png | Bin 0 -> 1870 bytes .../components/Layers/SoutheastGuideLayer.tsx | 10 +- frontend/src/service/ApiServiceNew.ts | 6 +- .../src/views/Reports/Chlorides/index.tsx | 106 +++++++++++++++++- .../views/WellManagement/WellSelectionMap.tsx | 30 +++-- 7 files changed, 140 insertions(+), 24 deletions(-) create mode 100644 frontend/src/assets/leaflet/marker-icon-red.png diff --git a/api/routes/chlorides.py b/api/routes/chlorides.py index 6c70128e..add44419 100644 --- a/api/routes/chlorides.py +++ b/api/routes/chlorides.py @@ -347,10 +347,10 @@ def _stats(values: List[float]) -> MinMaxAvg: ) # Approx NM bounding box (degrees) -NM_LAT_MIN = 31.3325 -NM_LAT_MAX = 37.0000 -NM_LON_MIN = -109.0500 -NM_LON_MAX = -103.0000 +NM_LAT_MIN = 33.12500 +NM_LAT_MAX = 34.12500 +NM_LON_MIN = -105.25000 +NM_LON_MAX = -104.25000 # Precompute midlines for quadrants NM_MID_LAT = (NM_LAT_MIN + NM_LAT_MAX) / 2.0 diff --git a/api/routes/wells.py b/api/routes/wells.py index fb2d0cd1..e9be8421 100644 --- a/api/routes/wells.py +++ b/api/routes/wells.py @@ -245,6 +245,7 @@ def create_well(new_well: well_schemas.SubmitWellCreate, db: Session = Depends(g ) def get_wells_locations( search_string: str = None, + has_chloride_group: bool = None, limit: int = 500, offset: int = 0, db: Session = Depends(get_db), @@ -270,6 +271,9 @@ def get_wells_locations( ) ) + if has_chloride_group is not None: + query_statement = query_statement.where(Wells.chloride_group_id.isnot(None)) + return db.scalars(query_statement.offset(offset).limit(limit)).all() diff --git a/frontend/src/assets/leaflet/marker-icon-red.png b/frontend/src/assets/leaflet/marker-icon-red.png new file mode 100644 index 0000000000000000000000000000000000000000..3e64e06d1db4eecf0d6e4446630fc4f9b97694b2 GIT binary patch literal 1870 zcmV-U2eJ5xP)P001cn1^@s6z>|W`00006VoOIv0RI60 z0RN!9r;`8x010qNS#tmY3ljhU3ljkVnw%H_000McNliru-3$s2DJLWAuf6~P2HQzQ zK~z}7rI%}rRaX_q|9kIq?mdtDm=_dhinS(Y;uopV!qk}Bl$f@L!Ut@OuNZ3_W2=5D z1dRqIF=(V8wDD16Ta7VRQ>X%>X|+&D3<52*kWi~2!^{+hdEI;G-t*XJ@3s7JVd!OM zm^-?Y|Cf{fU%#^-YprtxGozjH;)gbM8OT0i#WD$+IMv6)ds;^y~vi_r0+s$almBxo(}a6;ggXEEW-lK|b&M zkU1AZ2rx6q06LmM6h*pLsS0NPUJ%fZdtcsb7KI#GyYaoOZvt_$HhkU1BbwHUv2DaulP&KUXdL&tajb3w=-*KNFC3Gs(=XQ%4w>2Wzu zU>otGd#-#BBJ?pmIccURCoKV=-1LvVFU*1LzjITMRs8SX-rjQP`!JOmu(fD+2&LeZ z3W#)yOQWL=p=|$#zyE0z0OYkvR`D+jrIHtT9`wXC$QUs596`!?pOZ1rQx*96Jc{MA zW3Br2wb&8>o?pA+)2!tBTe>^*wps^>S|l9@Zf60xo-W9aB62-la68M8Id{%nTdkv~ zt2-~08rZY;p)Ue}*tce5kP0*Y?%uw@gM^)Fv}}V@@VS|E`Hm7++`R_=ie-4~Z->!U>;PvLY`N)Y0qH6< z44Wj&-jss*o-ptFq%(l7OSledQ36pep#QU*=e<$y`Ph93Z(oV&MjbMjYw?rL5awN9 zFyA8y81P*MPIYsuu1Y!rahhV;U8^93Sn%qu7+8(Oq!5Z*{G4j|t^xrAjv*T~O9g?M zX3fkD%LJ!TSTqsM)l6vh6NC^TB3}YH6(tFTQmt>2WymWBW3Rq`Q^?rKGsv@@vlBuo zL`ebypOi%6a3jq!p;U{Rk`(2s6SD&XuLm0C);on|qi`L_t`599I&5LkCzu(}-0{(4x0TCx^+*@CI@7Xj78a^lZYCyMBw8cy z=HN*Mie_MHbqH?VYE>>`f-)YeR*yY$>ZJh(05rs|k$U~v<;#}^re>RUXqE!l>NKcU zfy}umI1Z>@htACGJO;I_UV08j$EIQp@%`BXDV_KQZH%tz4AKjmb?64M72w#w&LrU2 z00#5UlU|5QqLEr#?|!gz?<{2S#F31^Ke=45#maZUoV8rXwgo_iK1Lh$*fKrw@Xp`a z)_!8`lM_j*BC?Qy59Vyil7Wl5j!;b#VKaKVrJn%6qh}6X2Jqz2OcX2MYc{AGdc{oz zKn6Z8*6T5VCmuU{XmSn&0F1UfD>^~lWKf|$ZwFjRGVn2zY0PAngsXq>&bTd*$Ic#_ zWFdB4jG|bD!PV4nZxt%^G1O>81l~D#;z)Ht2mlx_x2tBXt?3jh3?LYb$|;1X=@gC3 z7;EhhZ;Uwy@|B|pYJ&N@Lvb3*zz2arL<@=qAbbxOq9`KCJ03lAsJc{6H=EWxXCZ`u^g;}!SvR7(owN-B03Fd&skQYfoj?}y^WG>?3^An>q}J5BZSaeQVl4>y>c0-Amg$?rnT>@X z0#deSx0H}x9>ZA{la$~1_?dlr$q)b->we!enrM1Tr%+)IAU!LmkjIoxky@JQez5yj zH;Z#q$o7Lz87r{uB60IGF!s9lo6MTa export const SoutheastGuideLayer = () => ( - + {/* Lower than your GeoJSON panes (you used 600/625); markers still clickable above */} diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 7460f3b4..51f78074 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -423,7 +423,7 @@ export function useGetWells(params: WellListQueryParams | undefined) { ); } -export function useGetWellLocations(searchstring: string | undefined) { +export function useGetWellLocations(searchstring: string | undefined, has_chloride_group: boolean | null = null) { const route = "well_locations"; const authHeader = useAuthHeader(); const navigate = useNavigate(); @@ -431,11 +431,11 @@ export function useGetWellLocations(searchstring: string | undefined) { const PAGE_SIZE = 500; return useInfiniteQuery({ - queryKey: [route, searchstring], + queryKey: [route, searchstring, has_chloride_group], queryFn: async ({ pageParam = 0 }) => { return GETFetch( route, - { search_string: searchstring, offset: pageParam, limit: PAGE_SIZE }, + { search_string: searchstring, offset: pageParam, limit: PAGE_SIZE, has_chloride_group }, authHeader(), signOut, navigate diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 158e4c7c..ffcf5e14 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { ArrowBack, PictureAsPdf, Science } from "@mui/icons-material"; import { useMutation, useQuery } from "react-query"; import dayjs, { Dayjs } from "dayjs"; @@ -16,22 +17,36 @@ import { Divider, Box, } from "@mui/material"; -import { - MapContainer, - LayersControl, -} 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 } from "../../../components"; import { useFetchWithAuth } from "../../../hooks"; +import { useGetWellLocations } from "../../../service/ApiServiceNew"; +import { Well } from "../../../interfaces"; + +import iconRed from "../../../assets/leaflet/marker-icon-red.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; +// @ts-ignore +import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "leaflet/dist/leaflet.css"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; +const redIcon = L.icon({ + iconUrl: iconRed, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + const schema = yup.object().shape({ from: yup.mixed().nullable().required("From date is required"), to: yup @@ -133,6 +148,16 @@ export const ChloridesReportView = () => { }); }; + const wellQuery = useGetWellLocations('', true); + + useEffect(() => { + if (wellQuery.hasNextPage && !wellQuery.isFetchingNextPage) { + wellQuery.fetchNextPage(); + } + }, [wellQuery.hasNextPage, wellQuery.isFetchingNextPage]); + + const wellMarkers = wellQuery.data?.pages.flat() ?? []; + return ( @@ -208,7 +233,7 @@ export const ChloridesReportView = () => { sx={{ py: 3, px: 2 }} > - Chloride Reading: + Chlorides Reading: {chloridesQuery.isLoading && ( {[0, 1, 2, 3].map((i) => ( @@ -289,9 +314,80 @@ export const ChloridesReportView = () => { + + {/* Wells Cluster Overlay */} + + { + const count = cluster.getChildCount(); + return L.divIcon({ + html: `
${count}
`, + className: "", + iconSize: [40, 40], + }); + }} + > + {wellQuery.isSuccess && + wellMarkers.map((well: Well) => ( + + + {well.name || well.ra_number || well.id} + + + ))} +
+
+ {/* Loading first page */} + {wellQuery.isLoading && ( + + Loading well markers... + + )} + {/* Loading additional pages */} + {wellQuery.isFetchingNextPage && ( + + Loading more wells... + + )} + {wellQuery.isSuccess && wellMarkers.length === 0 && ( + + + No wells found for that search. + + + )} + {/* Error */} + {wellQuery.isError && ( + + + Failed to load wells: {wellQuery.error.message} + + + )} diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index 95ae235e..dba55766 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -4,12 +4,31 @@ import { useDebounce } from "use-debounce"; import { LayersControl, MapContainer, Marker, Tooltip } from "react-leaflet"; import L from "leaflet"; +import iconBlue from "leaflet/dist/images/marker-icon.png"; +import iconRed from "../../assets/leaflet/marker-icon-red.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +const blueIcon = L.icon({ + iconUrl: iconBlue, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +const redIcon = L.icon({ + iconUrl: iconRed, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + import "leaflet/dist/leaflet.css"; import { useGetWellLocations } from "../../service/ApiServiceNew"; import { Well } from "../../interfaces"; - -import icon from "leaflet/dist/images/marker-icon.png"; -import iconShadow from "leaflet/dist/images/marker-shadow.png"; import { Box, Typography } from "@mui/material"; import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer } from "../../components"; @@ -17,10 +36,6 @@ import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer } from "../../c import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import "@changey/react-leaflet-markercluster/dist/styles.min.css"; -const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); - -L.Marker.prototype.options.icon = DefaultIcon; - export default function WellSelectionMap({ setSelectedWell, wellSearchQueryProp, @@ -99,6 +114,7 @@ export default function WellSelectionMap({ eventHandlers={{ click: () => setSelectedWell(well), }} + icon={well.chloride_group_id != null ? redIcon : blueIcon} > {well.name || well.ra_number || well.id} From 878339e3d26ae8c2f63d546d5ec5442289258e0a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 27 Aug 2025 15:02:28 -0500 Subject: [PATCH 121/146] [components] Update meter legend & add well legend --- .../src/components/MeterMapColorLegend.tsx | 57 ++++++++++++ frontend/src/components/WellMapLegend.tsx | 59 +++++++++++++ frontend/src/components/index.ts | 1 + frontend/src/constants.ts | 11 +++ frontend/src/utils/GetMeterMarkerColor.ts | 14 +++ frontend/src/utils/index.ts | 1 + .../MeterSelection/MeterSelectionMap.tsx | 87 +++++-------------- .../src/views/Reports/Chlorides/index.tsx | 23 +++-- .../views/WellManagement/WellSelectionMap.tsx | 23 +++-- 9 files changed, 203 insertions(+), 73 deletions(-) create mode 100644 frontend/src/components/MeterMapColorLegend.tsx create mode 100644 frontend/src/components/WellMapLegend.tsx create mode 100644 frontend/src/constants.ts create mode 100644 frontend/src/utils/GetMeterMarkerColor.ts diff --git a/frontend/src/components/MeterMapColorLegend.tsx b/frontend/src/components/MeterMapColorLegend.tsx new file mode 100644 index 00000000..fad8515b --- /dev/null +++ b/frontend/src/components/MeterMapColorLegend.tsx @@ -0,0 +1,57 @@ +import { useEffect } from "react"; +import { useLeafletContext } from "@react-leaflet/core"; +import L from "leaflet"; +import { PM_COLORS } from "../constants"; + +export const MeterMapColorLegend = () => { + const context = useLeafletContext(); + + useEffect(() => { + const legend = new L.Control({ position: "bottomleft" }); + + legend.onAdd = function() { + const div = L.DomUtil.create("div", "info legend"); + + div.style.background = "white"; + div.style.padding = "10px"; + div.style.borderRadius = "8px"; + div.style.boxShadow = "0 2px 6px rgba(0,0,0,0.3)"; + div.style.fontSize = "14px"; + div.style.lineHeight = "18px"; + + const title = L.DomUtil.create("h4", "", div); + title.textContent = "PM Season"; + title.style.margin = "0 0 8px 0"; + + for (const season in PM_COLORS) { + const row = L.DomUtil.create("div", "", div); + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.marginBottom = "6px"; + + const colorBox = L.DomUtil.create("div", "", row); + colorBox.style.width = "20px"; + colorBox.style.height = "20px"; + colorBox.style.background = PM_COLORS[season]; + colorBox.style.marginRight = "8px"; + colorBox.style.border = "1px solid #ccc"; + colorBox.style.borderRadius = "4px"; + + const label = L.DomUtil.create("span", "", row); + label.textContent = season; + } + + return div; + }; + + const container = context.map; + container.addControl(legend); + + return () => { + container.removeControl(legend); + }; + }, [context.map]); + + return null; +} + diff --git a/frontend/src/components/WellMapLegend.tsx b/frontend/src/components/WellMapLegend.tsx new file mode 100644 index 00000000..d6a5e8d3 --- /dev/null +++ b/frontend/src/components/WellMapLegend.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import L from "leaflet"; +import iconBlue from "leaflet/dist/images/marker-icon.png"; +import iconRed from "../assets/leaflet/marker-icon-red.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +const blueIcon = L.icon({ + iconUrl: iconBlue, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +const redIcon = L.icon({ + iconUrl: iconRed, + shadowUrl: iconShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); + +export const WellMapLegend: React.FC = () => { + return ( +
+
+ Well + Well +
+
+ Chloride Monitored Well + Chloride Monitored Well +
+
+ ); +}; + diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 588432f8..386f9afa 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -18,3 +18,4 @@ export * from './WorkOrderSelect' export * from './GridFooterWithButton' export * from './NavLink' export * from './Topbar' +export * from './WellMapLegend' diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 00000000..bf8eaaba --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,11 @@ +export const PM_COLORS: { [key: string]: string } = { + "2020/2021": "brown", + "2021/2022": "green", + "2022/2023": "purple", + "2023/2024": "turquoise", + "2024/2025": "red", + "2025/2026": "white", + "2026/2027": "yellow", + "2027/2028": "brown", + "2028/2029": "blue", +}; diff --git a/frontend/src/utils/GetMeterMarkerColor.ts b/frontend/src/utils/GetMeterMarkerColor.ts new file mode 100644 index 00000000..540d60d1 --- /dev/null +++ b/frontend/src/utils/GetMeterMarkerColor.ts @@ -0,0 +1,14 @@ +import { PM_COLORS } from "../constants"; + +export const getMeterMarkerColor = (last_pm: string) => { + const last_pm_date = new Date(last_pm); + if (last_pm_date.getMonth() >= 7) { + return PM_COLORS[ + last_pm_date.getFullYear() + "/" + (last_pm_date.getFullYear() + 1) + ]; + } else { + return PM_COLORS[ + last_pm_date.getFullYear() - 1 + "/" + last_pm_date.getFullYear() + ]; + } +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 2b7d0b72..2158b20b 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./DateUtils" export * from "./HttpUtils" +export * from "./GetMeterMarkerColor" export * from "./MonitoredWellsUtils" export * from "./NumberDataFormatter" diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 558b7662..f0cba18b 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -1,4 +1,3 @@ -import { useEffect } from "react"; import { useDebounce } from "use-debounce"; import { MapContainer, @@ -11,7 +10,6 @@ import { import { MeterMapDTO } from "../../../interfaces"; import L from "leaflet"; -import { useLeafletContext } from "@react-leaflet/core"; import { FeatureCollection } from "geojson"; import "leaflet/dist/leaflet.css"; @@ -28,63 +26,12 @@ import { Box, Typography } from "@mui/material"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; import { OpenStreetMapLayer, SatelliteLayer } from "../../../components"; +import { getMeterMarkerColor } from "../../../utils"; +import { MeterMapColorLegend } from "../../../components/MeterMapColorLegend"; const DefaultIcon = L.icon({ iconUrl: icon, shadowUrl: iconShadow }); L.Marker.prototype.options.icon = DefaultIcon; -// Define marker colors which are based on the year of the last PM (July - June) -const pm_colors: { [key: string]: string } = { - "2020/2021": "brown", - "2021/2022": "green", - "2022/2023": "purple", - "2023/2024": "turquoise", - "2024/2025": "red", - "2025/2026": "white", - "2026/2027": "yellow", - "2027/2028": "brown", - "2028/2029": "blue", -}; - -// Color legend component -function ColorLegend() { - const context = useLeafletContext(); - - useEffect(() => { - const legend = new L.Control({ position: "bottomleft" }); - legend.onAdd = function() { - const div = L.DomUtil.create("div", "info legend"); - div.innerHTML = "

PM Season

"; - for (const season in pm_colors) { - div.innerHTML += ` ${season}
`; - } - return div; - }; - - const container = context.map; - container.addControl(legend); - - return () => { - container.removeControl(legend); - }; - }, [context.map]); - - return null; -} - -// Function for getting color from last PM -function getMeterColor(last_pm: string) { - const last_pm_date = new Date(last_pm); - if (last_pm_date.getMonth() >= 7) { - return pm_colors[ - last_pm_date.getFullYear() + "/" + (last_pm_date.getFullYear() + 1) - ]; - } else { - return pm_colors[ - last_pm_date.getFullYear() - 1 + "/" + last_pm_date.getFullYear() - ]; - } -} - // Static geojson data const trData: FeatureCollection = tr_data as FeatureCollection; const ssData: FeatureCollection = ss_data as FeatureCollection; @@ -150,7 +97,7 @@ export default function MeterSelectionMap({ > {meterMarkers.isSuccess && meterMarkers.data.map((meter: MeterMapDTO) => { - const color = meter.last_pm ? getMeterColor(meter.last_pm) : "black"; + const color = meter.last_pm ? getMeterMarkerColor(meter.last_pm) : "black"; return ( - - +
- {/* Loading and empty states */} {meterMarkers.isLoading && ( - Loading meter markers... + Loading meter markers... )} - - {meterMarkers.isSuccess && meterMarkers.data.length === 0 && ( + {meterMarkers.isSuccess && meterMarkers?.data.length === 0 && ( - + No meters found for that search. )} + {/* Error */} + {meterMarkers.isError && ( + + + Failed to load meters: {meterMarkers.error.message} + + + )} ); } diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index ffcf5e14..6aa5fb10 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -25,7 +25,7 @@ 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 } from "../../../components"; +import { CustomCardHeader, BackgroundBox, DirectionCard, SoutheastGuideLayer, SatelliteLayer, OpenStreetMapLayer, WellMapLegend } from "../../../components"; import { useFetchWithAuth } from "../../../hooks"; import { useGetWellLocations } from "../../../service/ApiServiceNew"; import { Well } from "../../../interfaces"; @@ -359,23 +359,33 @@ 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. @@ -383,7 +393,10 @@ export const ChloridesReportView = () => { {/* Error */} {wellQuery.isError && ( - + Failed to load wells: {wellQuery.error.message} diff --git a/frontend/src/views/WellManagement/WellSelectionMap.tsx b/frontend/src/views/WellManagement/WellSelectionMap.tsx index dba55766..89992253 100644 --- a/frontend/src/views/WellManagement/WellSelectionMap.tsx +++ b/frontend/src/views/WellManagement/WellSelectionMap.tsx @@ -30,7 +30,7 @@ import "leaflet/dist/leaflet.css"; import { useGetWellLocations } from "../../service/ApiServiceNew"; import { Well } from "../../interfaces"; import { Box, Typography } from "@mui/material"; -import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer } from "../../components"; +import { OpenStreetMapLayer, SatelliteLayer, SoutheastGuideLayer, WellMapLegend } from "../../components"; // @ts-ignore import MarkerClusterGroup from "@changey/react-leaflet-markercluster"; @@ -122,6 +122,7 @@ export default function WellSelectionMap({ ))} + @@ -129,18 +130,27 @@ 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. @@ -148,7 +158,10 @@ export default function WellSelectionMap({ {/* Error */} {wellQuery.isError && ( - + Failed to load wells: {wellQuery.error.message} From a7a2ec236eda5ee0f790758d29b7fce636c2d0c6 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Thu, 21 Aug 2025 22:39:50 -0500 Subject: [PATCH 122/146] [routes/admin] Add /backup-db route --- api/requirements.txt | 1 + api/routes/admin.py | 63 +++++++++++++++++++++++++++++++++- docker-compose.development.yml | 3 ++ docker-compose.production.yml | 17 ++++++--- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/api/requirements.txt b/api/requirements.txt index f2b8c1a8..4e25f042 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -69,3 +69,4 @@ weasyprint==65.1 webencodings==0.5.1 xlsxwriter==3.2.5 zopfli==0.2.3.post1 +google-cloud-storage==3.3.0 diff --git a/api/routes/admin.py b/api/routes/admin.py index 0d0a8b18..189e6130 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -11,10 +11,23 @@ from api.route_util import _patch from api.enums import ScopedUser +from pathlib import Path +from google.cloud import storage +from dotenv import load_dotenv + +import os +import subprocess +import datetime + admin_router = APIRouter() pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - +BUCKET_NAME = os.getenv("GCP_BUCKET_NAME", "") +BACKUP_PREFIX = os.getenv("GCP_BACKUP_PREFIX", "") +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", @@ -195,3 +208,51 @@ def update_role(updated_role: security_schemas.UserRole, db: Session = Depends(g .where(UserRoles.id == updated_role.id) .options(joinedload(UserRoles.security_scopes)) ).first() + + +@admin_router.api_route( + "/backup-db/", + methods=["BACKUP"], + tags=["Admin"], + dependencies=[Depends(ScopedUser.Admin)] +) +def backup_and_send(): + if not BUCKET_NAME: + raise ValueError("GCP_BUCKET_NAME environment variable is not set") + if not DATABASE_URL: + raise ValueError("DATABASE_URL environment variable is not set") + + timestamp = datetime.datetime.utcnow().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 + ) + + 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) + + print(f"Backup uploaded to gs://{BUCKET_NAME}/{blob_name}") + + local_path.unlink(missing_ok=True) + + # Delete old backups (> BACKUP_RETENTION_DAYS) + cutoff_date = datetime.datetime.utcnow() - datetime.timedelta(days=BACKUP_RETENTION_DAYS) + blobs = client.list_blobs(BUCKET_NAME, prefix=BACKUP_PREFIX) + + deleted = [] + for old_blob in blobs: + 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 + } diff --git a/docker-compose.development.yml b/docker-compose.development.yml index 6da5fdaf..f26dffe8 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -31,6 +31,9 @@ services: dockerfile: ./Dockerfile working_dir: /app environment: + - GCP_BUCKET_NAME=pvacd + - GCP_BACKUP_PREFIX=pre-prod-db-backups + - BACKUP_RETENTION_DAYS=14 - APPDB_ENV=.env_devserver command: > uvicorn api.main:app diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 3b9796f4..4cf17750 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -29,11 +29,18 @@ services: build: context: ./api dockerfile: ./Dockerfile - command: bash -c " - uvicorn api.main:app - --host 0.0.0.0 - --proxy-headers --root-path /api/v1 - " + working_dir: /app + environment: + - GCP_BUCKET_NAME=pvacd + - GCP_BACKUP_PREFIX=prod-db-backups + - BACKUP_RETENTION_DAYS=90 + - APPDB_ENV=.env_production + command: > + uvicorn api.main:app + --host 0.0.0.0 + --port 8000 + --proxy-headers + --root-path /api/v1 ports: - "8000:8000" volumes: From 6310e4704ed027d088b7c055f4a32a4577a99aed Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 27 Aug 2025 13:21:42 -0700 Subject: [PATCH 123/146] [meter-manager] init daemon service --- meter-manager.service | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 meter-manager.service diff --git a/meter-manager.service b/meter-manager.service new file mode 100644 index 00000000..bf6c906e --- /dev/null +++ b/meter-manager.service @@ -0,0 +1,20 @@ +[Unit] +Description=Meter Manager Docker Compose Daemon +Requires=docker.service +After=docker.service + +[Service] +User=deploy +WorkingDirectory=/home/deploy/WaterManagerDB + +# Pick compose file based on ENV (METER_MANAGER_ENV) +# Default is production +Environment="METER_MANAGER_ENV=production" + +ExecStart=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml up -d +ExecStop=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml down + +Restart=always + +[Install] +WantedBy=multi-user.target From b2f7efebd577b5a8e85afb113a7e221745ea64db Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 27 Aug 2025 14:02:26 -0700 Subject: [PATCH 124/146] [backup] init db shell script for cron job --- backup.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 backup.sh diff --git a/backup.sh b/backup.sh new file mode 100644 index 00000000..33cafe1f --- /dev/null +++ b/backup.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +# Load ENV variables +API="${API:-http://localhost:8000}" +USERNAME="${USERNAME:?Missing USERNAME env}" +PASSWORD="${PASSWORD:?Missing PASSWORD env}" + +# Get token +TOKEN=$(curl -s -X POST "$API/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=$USERNAME&password=$PASSWORD" \ + | jq -r .access_token) + +# Call backup endpoint +curl -s -X BACKUP "$API/backup-db/" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" From 2c1ad4c79c59ccbd20aaee028fc0f49dbd74b01a Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 29 Aug 2025 12:22:42 -0500 Subject: [PATCH 125/146] [AppLayout] Update app layout --- frontend/index.html | 127 +++++++++---------- frontend/src/App.tsx | 123 +----------------- frontend/src/AppLayout.tsx | 89 +++++++++++++ frontend/src/components/Topbar.tsx | 164 +++++++++++++----------- frontend/src/service/ApiServiceNew.ts | 17 +-- frontend/src/sidenav.tsx | 175 +++++++++++++++++--------- 6 files changed, 371 insertions(+), 324 deletions(-) create mode 100644 frontend/src/AppLayout.tsx diff --git a/frontend/index.html b/frontend/index.html index 0204c989..ffa57ae2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,77 +1,66 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - Meter Manager DB - - - - -
- - - + .flex-container { + display: flex; + } + + .flex-child { + flex: 1; + border: 2px solid blue; + } + + .flex-child:first-child { + margin-right: 20px; + width: 700px; + } + +
+ + + + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9757e930..435ccbfd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,14 @@ -import { useEffect, useRef, useState } from "react"; -import { AuthProvider, useAuthUser } from "react-auth-kit"; +import { useEffect, useState } from "react"; +import { AuthProvider } from "react-auth-kit"; import { Route, BrowserRouter as Router, Routes, - useNavigate, } from "react-router-dom"; 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 { Box } from "@mui/material"; import { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; import { MetersView } from "./views/Meters/MetersView"; @@ -18,11 +16,8 @@ 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 Sidenav from "./sidenav"; import { Home } from "./Home"; -import Topbar from "./components/Topbar"; import Login from "./login"; -import { SecurityScope } from "./interfaces"; import { ChloridesView } from "./views/Chlorides/ChloridesView"; import { ReportsView } from "./views/Reports"; import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; @@ -31,119 +26,7 @@ import { MaintenanceReportView } from "./views/Reports/Maintenance"; import { PartsUsedReportView } from "./views/Reports/PartsUsed"; import { BoardReportView } from "./views/Reports/Board"; import { ChloridesReportView } from "./views/Reports/Chlorides"; - -// A wrapper that handles checking that the user is logged in and has any necessary scopes -const AppLayout = ({ - pageComponent, - requiredScopes = null, - setErrorMessage = null, -}: any) => { - const authUser = useAuthUser(); - const navigate = useNavigate(); - - const isLoggedIn = authUser() != null; - const userScopes = authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string, - ); - const hasScopes = - requiredScopes == null - ? true - : requiredScopes?.every((scope: string) => userScopes?.includes(scope)); - - useEffect(() => { - if (!isLoggedIn) { - if (setErrorMessage) setErrorMessage("You must login to view pages."); - navigate("/"); - } else if (!hasScopes) { - if (setErrorMessage) - setErrorMessage( - "You do not have sufficient permissions to view this page.", - ); - navigate("/home"); - } - }, [authUser()]); - - const topbarRef = useRef(null); - const sidenavRef = useRef(null); - - const [topbarHeight, setTopbarHeight] = useState(0); - const [sidenavWidth, setSidenavWidth] = useState(0); - - // Resize observer for topbar height - useEffect(() => { - if (!topbarRef.current) return; - - const observer = new ResizeObserver(() => { - setTopbarHeight(topbarRef.current!.offsetHeight); - }); - - observer.observe(topbarRef.current); - - return () => observer.disconnect(); - }, []); - - // Resize observer for sidenav width - useEffect(() => { - if (!sidenavRef.current) return; - - const observer = new ResizeObserver(() => { - setSidenavWidth(sidenavRef.current!.offsetWidth); - }); - - observer.observe(sidenavRef.current); - - return () => observer.disconnect(); - }, []); - - if (isLoggedIn && hasScopes) - return ( - - - - - - - - - - - {pageComponent} - - - - ); - return null; -}; +import { AppLayout } from "./AppLayout"; export const App = () => { const queryClient = new QueryClient(); diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx new file mode 100644 index 00000000..15b0b74e --- /dev/null +++ b/frontend/src/AppLayout.tsx @@ -0,0 +1,89 @@ +import { useAuthUser } from "react-auth-kit"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Box } from "@mui/material"; +import { Theme } from "@mui/material/styles"; +import { SecurityScope } from "./interfaces"; +import Topbar from "./components/Topbar"; +import Sidenav from "./sidenav"; + +const drawerWidth = 270; + +export const AppLayout = ({ + pageComponent, + requiredScopes = null, + setErrorMessage = null, +}: any) => { + const authUser = useAuthUser(); + const navigate = useNavigate(); + + const isLoggedIn = authUser() != null; + const userScopes = authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string + ); + const hasScopes = + requiredScopes == null + ? true + : requiredScopes?.every((scope: string) => userScopes?.includes(scope)); + + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + if (!isLoggedIn) { + if (setErrorMessage) setErrorMessage("You must login to view pages."); + navigate("/"); + } else if (!hasScopes) { + if (setErrorMessage) + setErrorMessage( + "You do not have sufficient permissions to view this page." + ); + navigate("/home"); + } + }, [isLoggedIn, hasScopes]); + + if (!isLoggedIn || !hasScopes) return null; + + return ( + + setDrawerOpen(!drawerOpen)} + sx={(theme: Theme) => ({ + zIndex: theme.zIndex.drawer + 1, + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + ...(drawerOpen && { + width: `calc(100% - ${drawerWidth}px)`, + ml: `${drawerWidth}px`, + transition: theme.transitions.create(["margin", "width"], { + easing: theme.transitions.easing.easeOut, + duration: theme.transitions.duration.enteringScreen, + }), + }), + })} + /> + setDrawerOpen(false)} + /> + ({ + flexGrow: 1, + p: 3, + mt: 8, + ...theme.mixins.toolbar, + transition: theme.transitions.create("margin", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + })} + > + {pageComponent} + + + ); +}; + diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 1c94ef6c..43546b75 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -1,16 +1,20 @@ -import { useState, useRef } from "react"; import { - Button, + AppBar, + Toolbar, + Typography, + IconButton, + Avatar, Menu, MenuItem, - Avatar, - Grid, - Typography, + Button, + Box, } from "@mui/material"; +import MenuIcon from "@mui/icons-material/Menu"; import { useLocation, useNavigate } from "react-router-dom"; -import { useSignOut, useAuthUser } from "react-auth-kit"; +import { useAuthUser, useSignOut } from "react-auth-kit"; +import { useRef, useState } from "react"; -export default function Topbar() { +export default function Topbar({ onMenuClick, sx }: { onMenuClick: () => void; sx?: any }) { const location = useLocation(); const navigate = useNavigate(); const signOut = useSignOut(); @@ -25,73 +29,89 @@ export default function Topbar() { }; return ( - - - navigate("/home")} - > - Meter Manager - - - - {location.pathname !== "/" && ( - - - - setProfileMenuOpen(false)} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - transformOrigin={{ horizontal: "right", vertical: "top" }} + + + navigate("/home")} > - Logout - - - )} - + Meter Manager + + + + {location.pathname !== "/" && ( + + + setProfileMenuOpen(false)} + anchorOrigin={{ horizontal: "right", vertical: "bottom" }} + transformOrigin={{ horizontal: "right", vertical: "top" }} + > + { + navigate("/settings"); + setProfileMenuOpen(false); + }} + > + Settings + + Logout + + + )} + + ); } -const styles = { - container: { - zIndex: "100 !important", - justifyContent: "space-between", - backgroundColor: "white", - py: 1, - }, - button: { - marginTop: "auto", - marginBottom: "auto", - }, - avatar: { - width: 32, - height: 32, - marginRight: 1, - }, -}; diff --git a/frontend/src/service/ApiServiceNew.ts b/frontend/src/service/ApiServiceNew.ts index 51f78074..b1af9629 100644 --- a/frontend/src/service/ApiServiceNew.ts +++ b/frontend/src/service/ApiServiceNew.ts @@ -1,4 +1,4 @@ -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } 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 { @@ -558,23 +558,26 @@ export function useGetST2WaterLevels(datastreamID: number | undefined) { ); } -export function useGetWorkOrders(status_filter: WorkOrderStatus[]) { +export function useGetWorkOrders( + status_filter: WorkOrderStatus[], + options?: UseQueryOptions +) { const route = "work_orders"; const authHeader = useAuthHeader(); const navigate = useNavigate(); const signOut = useSignOut(); - //Convert status filter array to - - return useQuery([route, status_filter], () => - GETFetch( + return useQuery({ + queryKey: [route, { status_filter: status_filter.sort() }], + queryFn: () => GETFetch( route, { filter_by_status: status_filter }, authHeader(), signOut, navigate, ), - ); + ...options + }); } export function useCreateUser(onSuccess: Function) { diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index fbfbacd7..9d363243 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -1,12 +1,14 @@ import { useEffect, useState } from "react"; import { useAuthUser } from "react-auth-kit"; -import { Grid } from "@mui/material"; +import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/material"; +import { useNavigate } from "react-router-dom"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; import { WorkOrder } from "./interfaces"; import { Assessment, Build, + ChevronLeft, Construction, FormatListBulletedOutlined, Home, @@ -18,7 +20,16 @@ import { } from "@mui/icons-material"; import { NavLink } from "./components/NavLink"; -export default function Sidenav() { +export default function Sidenav({ + open, + drawerWidth, + onClose, +}: { + open: boolean; + drawerWidth: number; + onClose: () => void; +}) { + const navigate = useNavigate(); const authUser = useAuthUser(); const hasAdminScope = authUser() ?.user_role.security_scopes.map((scope: any) => scope.scope_string) @@ -26,72 +37,124 @@ export default function Sidenav() { const userID = authUser()?.id; const [workOrderLabel, setWorkOrderLabel] = useState("Work Orders"); - const workOrderList = useGetWorkOrders([WorkOrderStatus.Open]); + const workOrderList = useGetWorkOrders([WorkOrderStatus.Open], { + refetchInterval: 30_000, + refetchIntervalInBackground: true, + }); useEffect(() => { if (workOrderList.data && userID) { - let userWorkOrders = workOrderList.data.filter( - (workOrder: WorkOrder) => workOrder.assigned_user_id == userID, + const userWorkOrders = workOrderList.data.filter( + (workOrder: WorkOrder) => workOrder.assigned_user_id == userID + ); + setWorkOrderLabel( + userWorkOrders.length > 0 + ? `Work Orders (${userWorkOrders.length})` + : "Work Orders" ); - let numberOfWorkOrders = userWorkOrders.length; - if (numberOfWorkOrders > 0) { - setWorkOrderLabel(`Work Orders (${numberOfWorkOrders})`); - } else { - setWorkOrderLabel("Work Orders"); - } } }, [workOrderList.data, userID]); - //Refresh work order list once a minute - useEffect(() => { - const interval = setInterval(() => { - workOrderList.refetch(); - }, 60000); - return () => clearInterval(interval); - }, []); - return ( - - -
Pages
-
+ + navigate("/home")} + > + Meter Manager + + + + + + + + + +
Pages
+
- - - - - - - + + + + + + + - {hasAdminScope && ( - <> - -
Admin Management
-
- - - - - )} -
+ {hasAdminScope && ( + <> + +
+ Admin Management +
+
+ + + + + )} +
+ ); } From c4bf0e9fc808a03ae6bc81c01499e5209aa871a7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 29 Aug 2025 13:40:32 -0500 Subject: [PATCH 126/146] [login] Improve UI & routing --- frontend/src/App.tsx | 12 ++- frontend/src/AppLayout.tsx | 53 +++++++--- frontend/src/components/Topbar.tsx | 89 ++++++++++++---- frontend/src/css/sidebar.css | 44 -------- frontend/src/css/topbar.css | 57 ----------- frontend/src/login.tsx | 158 ++++++++++++++--------------- frontend/src/sidenav.tsx | 78 +++++++------- 7 files changed, 243 insertions(+), 248 deletions(-) delete mode 100644 frontend/src/css/sidebar.css delete mode 100644 frontend/src/css/topbar.css diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 435ccbfd..b7fe3dd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,13 +54,19 @@ export const App = () => { > - } /> + } + requiredScopes={[]} + setErrorMessage={setErrorMessage} + /> + } /> } - requiredScopes={["read"]} + requiredScopes={[]} setErrorMessage={setErrorMessage} /> } diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 15b0b74e..42003522 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -1,13 +1,13 @@ import { useAuthUser } from "react-auth-kit"; import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { Box } from "@mui/material"; import { Theme } from "@mui/material/styles"; import { SecurityScope } from "./interfaces"; import Topbar from "./components/Topbar"; import Sidenav from "./sidenav"; -const drawerWidth = 270; +const drawerWidth = 300; export const AppLayout = ({ pageComponent, @@ -16,32 +16,59 @@ export const AppLayout = ({ }: any) => { const authUser = useAuthUser(); const navigate = useNavigate(); + const location = useLocation(); const isLoggedIn = authUser() != null; - const userScopes = authUser()?.user_role?.security_scopes?.map( - (scope: SecurityScope) => scope.scope_string - ); + const userScopes: string[] = + authUser()?.user_role?.security_scopes?.map( + (scope: SecurityScope) => scope.scope_string + ) ?? []; + const hasScopes = requiredScopes == null ? true - : requiredScopes?.every((scope: string) => userScopes?.includes(scope)); + : requiredScopes?.every((scope: string) => + userScopes?.includes(scope) + ); const [drawerOpen, setDrawerOpen] = useState(false); useEffect(() => { + const currentPath = location.pathname; + + // Case 1: Not logged in if (!isLoggedIn) { - if (setErrorMessage) setErrorMessage("You must login to view pages."); - navigate("/"); - } else if (!hasScopes) { + const allowedRoutes = ["/", "/login"]; + if (!allowedRoutes.includes(currentPath)) { + if (setErrorMessage) + setErrorMessage("You must login to view pages."); + navigate("/login", { replace: true }); + } + return; + } + + // Case 2: Logged in but no scopes at all + if (userScopes.length === 0) { + const allowedRoutes = ["/", "/login"]; + if (!allowedRoutes.includes(currentPath)) { + if (setErrorMessage) + setErrorMessage( + "Your account does not have any permissions to view this page." + ); + navigate("/", { replace: true }); + } + return; + } + + // Case 3: Logged in but missing required scopes + if (!hasScopes) { if (setErrorMessage) setErrorMessage( "You do not have sufficient permissions to view this page." ); - navigate("/home"); + navigate("/", { replace: true }); } - }, [isLoggedIn, hasScopes]); - - if (!isLoggedIn || !hasScopes) return null; + }, [isLoggedIn, hasScopes, userScopes, location.pathname]); return ( diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 43546b75..23f3869f 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -10,24 +10,44 @@ import { Box, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuthUser, useSignOut } from "react-auth-kit"; -import { useRef, useState } from "react"; +import { useState } from "react"; +import { Badge, Engineering, Face, Login } from "@mui/icons-material"; export default function Topbar({ onMenuClick, sx }: { onMenuClick: () => void; sx?: any }) { - const location = useLocation(); const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); + const role = authUser()?.user_role?.name; + const isLoggedIn = !!authUser(); - const profileMenuRef = useRef(null); - const [isProfileMenuOpen, setProfileMenuOpen] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const handleMenuOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleMenuClose = () => { + setAnchorEl(null); + }; const fullSignOut = () => { navigate("/"); signOut(); }; + const renderRoleIcon = () => { + switch (role) { + case "Admin": + return ; + case "Technician": + return ; + default: + return ; + } + }; + return ( void; s xl: "2rem", }, }} - onClick={() => navigate("/home")} + onClick={() => navigate("/")} > Meter Manager - {location.pathname !== "/" && ( + {isLoggedIn ? ( setProfileMenuOpen(false)} + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleMenuClose} anchorOrigin={{ horizontal: "right", vertical: "bottom" }} transformOrigin={{ horizontal: "right", vertical: "top" }} > { - navigate("/settings"); - setProfileMenuOpen(false); + navigate("/settings") + handleMenuClose() }} > Settings - Logout + { + fullSignOut() + handleMenuClose() + }}>Logout - )} + ) + : ( + + )} ); diff --git a/frontend/src/css/sidebar.css b/frontend/src/css/sidebar.css deleted file mode 100644 index 0aa53806..00000000 --- a/frontend/src/css/sidebar.css +++ /dev/null @@ -1,44 +0,0 @@ -.sidebar { - width: 250px; - height: calc(100vh - 50px); - background-color: rgb(251, 251, 255); - position: sticky; - top: 50px; -} - -.sidebarWrapper { - padding: 20px; - color: #555; -} - -.sidebarMenu { - margin-bottom: 10px; -} - -.sidebarTitle { - font-size: 13px; - color: rgb(187, 186, 186); -} - -.sidebarList { - list-style: none; - padding: 5px; -} - -.sidebarListItem { - padding: 5px; - cursor: pointer; - display: flex; - align-items: center; - border-radius: 10px; -} - -.sidebarListItem.active, -.sidebarListItem:hover { - background-color: rgb(240, 240, 255); -} - -.sidebarIcon { - margin-right: 5px; - font-size: 20px !important; -} diff --git a/frontend/src/css/topbar.css b/frontend/src/css/topbar.css deleted file mode 100644 index 8f70a457..00000000 --- a/frontend/src/css/topbar.css +++ /dev/null @@ -1,57 +0,0 @@ -.topbar { - width: 100%; - height: 50px; - background-color: white; - position: sticky; - top: 0; - z-index: 999; -} - -.topbarWrapper { - height: 100%; - padding: 0px 20px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.logo { - font-weight: bold; - font-size: 30px; - color: darkblue; - cursor: pointer; -} - -.topRight { - display: flex; - align-items: center; -} - -.topbarIconContainer { - position: relative; - cursor: pointer; - margin-right: 10px; - color: #555; -} - -.topIconBadge { - width: 15px; - height: 15px; - position: absolute; - top: -5px; - right: 0px; - background-color: red; - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; -} - -.topAvatar { - width: 40px; - height: 40px; - border-radius: 50%; - cursor: pointer; -} diff --git a/frontend/src/login.tsx b/frontend/src/login.tsx index a3394c19..faaeefe4 100644 --- a/frontend/src/login.tsx +++ b/frontend/src/login.tsx @@ -7,16 +7,15 @@ import { Button, Card, CardContent, - CardHeader, Alert, - Divider, - Typography, Stack, Grid, } from "@mui/material"; +import LoginIcon from '@mui/icons-material/Login'; import { enqueueSnackbar } from "notistack"; import { SecurityScope } from "./interfaces"; import { API_URL } from "./config"; +import { CustomCardHeader } from "./components"; const Login = () => { const [username, setUsername] = useState(""); @@ -37,7 +36,7 @@ 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." ); }); }; @@ -52,7 +51,7 @@ 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; } @@ -66,8 +65,7 @@ const Login = () => { ) { localStorage.setItem("_auth", data.access_token); localStorage.setItem("loggedIn", "true"); - - navigate("/home"); + navigate("/"); } else { setError("Invalid username or password. Please try again."); } @@ -78,82 +76,84 @@ const Login = () => { } return ( - <> - - - PVACD Meter Manager Home - - - - Login - - } - sx={{ mb: 0, pb: 0 }} - /> - + + + + - - setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + - - - - - - {error?.trim() && ( - - {error} - - )} - - + Login + + + + + + {error?.trim() && ( + + {error} + + )} + ); }; export default Login; + diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index 9d363243..26e2ad0a 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -4,7 +4,7 @@ import { Box, Drawer, Grid, IconButton, Toolbar, Typography } from "@mui/materia import { useNavigate } from "react-router-dom"; import { useGetWorkOrders } from "./service/ApiServiceNew"; import { WorkOrderStatus } from "./enums"; -import { WorkOrder } from "./interfaces"; +import { SecurityScope, WorkOrder } from "./interfaces"; import { Assessment, Build, @@ -31,21 +31,29 @@ export default function Sidenav({ }) { const navigate = useNavigate(); const authUser = useAuthUser(); - const hasAdminScope = authUser() - ?.user_role.security_scopes.map((scope: any) => scope.scope_string) - .includes("admin"); + + // 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 + ) ?? [] + ); + + const hasReadScope = scopes.has("read"); + const hasAdminScope = scopes.has("admin"); const userID = authUser()?.id; const [workOrderLabel, setWorkOrderLabel] = useState("Work Orders"); const workOrderList = useGetWorkOrders([WorkOrderStatus.Open], { - refetchInterval: 30_000, + refetchInterval: 45_000, refetchIntervalInBackground: true, + enabled: hasReadScope && !!authUser() }); useEffect(() => { if (workOrderList.data && userID) { const userWorkOrders = workOrderList.data.filter( - (workOrder: WorkOrder) => workOrder.assigned_user_id == userID + (workOrder: WorkOrder) => workOrder.assigned_user_id === userID ); setWorkOrderLabel( userWorkOrders.length > 0 @@ -67,15 +75,16 @@ export default function Sidenav({ width: drawerWidth, boxSizing: "border-box", backgroundColor: "white", - overflowY: 'hidden', + overflowY: "hidden", }, }} > + {/* Header */} navigate("/home")} + onClick={() => navigate("/")} > Meter Manager @@ -110,6 +119,8 @@ export default function Sidenav({ + + {/* Nav Items */} Pages - - - - - - - + + + {hasReadScope && ( + <> + + + + + + + + )} {hasAdminScope && ( <> -
- Admin Management -
+
Admin Management
- + )} From 01cfac1b1154fdd4907416774cbc66e70d831729 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 29 Aug 2025 13:57:24 -0500 Subject: [PATCH 127/146] [Topbar] Add role to profile menu --- frontend/src/components/Topbar.tsx | 17 +++++++++++++++++ frontend/src/views/Reports/index.tsx | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx index 23f3869f..7bf3a109 100644 --- a/frontend/src/components/Topbar.tsx +++ b/frontend/src/components/Topbar.tsx @@ -8,6 +8,7 @@ import { MenuItem, Button, Box, + Divider, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import { useNavigate } from "react-router-dom"; @@ -122,6 +123,22 @@ export default function Topbar({ onMenuClick, sx }: { onMenuClick: () => void; s anchorOrigin={{ horizontal: "right", vertical: "bottom" }} transformOrigin={{ horizontal: "right", vertical: "top" }} > + + + + Role: {role ?? "Unknown"} + + + + { navigate("/settings") diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 6c9e4d07..c86b921c 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -19,12 +19,14 @@ export const ReportsView = () => { + {/* + */} { label="Parts Used" Icon={Build} /> + {/* + */} Date: Fri, 29 Aug 2025 15:38:43 -0500 Subject: [PATCH 128/146] [Home] Patch broken styles on smaller screens --- frontend/src/Home.tsx | 78 ++++++++++++++----- .../Meters/MeterHistory/MeterHistory.tsx | 4 +- .../Meters/MeterSelection/MeterSelection.tsx | 24 ++++-- frontend/src/views/Meters/MetersView.tsx | 7 +- 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/frontend/src/Home.tsx b/frontend/src/Home.tsx index 52999fe8..6b95dae6 100644 --- a/frontend/src/Home.tsx +++ b/frontend/src/Home.tsx @@ -1,4 +1,4 @@ -import { Box, Card, CardContent, Typography } from "@mui/material"; +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"; @@ -26,30 +26,66 @@ export const Home = () => { return ( - + - - - - PVACD Meter Manager Info - Version History -
    - {versionHistory.map((version) => ( -
  • {version}
  • - ))} -
- + + + + PVACD Meter Manager Info + Version History + + {versionHistory.map((version) => ( + + + + ))} + + + + + - - - -
-
+ + + + +
diff --git a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx index 1b3302f6..a24cc114 100644 --- a/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx +++ b/frontend/src/views/Meters/MeterHistory/MeterHistory.tsx @@ -146,13 +146,13 @@ export const MeterHistory = ({ return ( - + - + {getDetailsCard(selectedHistoryItem)} diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index a81b3728..d6a3dee0 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -11,10 +11,12 @@ import { CardContent, ToggleButtonGroup, 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"; export const MeterSelection = ({ onMeterSelection, @@ -74,22 +76,34 @@ export const MeterSelection = ({ /> - - + + - + { setMeterSearchQuery(e.target.value); }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> diff --git a/frontend/src/views/Meters/MetersView.tsx b/frontend/src/views/Meters/MetersView.tsx index f376b6c8..f8245ba3 100644 --- a/frontend/src/views/Meters/MetersView.tsx +++ b/frontend/src/views/Meters/MetersView.tsx @@ -36,24 +36,23 @@ export const MetersView = () => { - + - + - + From e401cb3fcefec4b4599d7468198e8a1bf86be792 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Fri, 29 Aug 2025 16:22:04 -0500 Subject: [PATCH 129/146] [WorkOrderTable] Fix work order table styles --- frontend/src/AppLayout.tsx | 2 + .../Meters/MeterSelection/MeterSelection.tsx | 5 +- .../MeterSelection/MeterSelectionTable.tsx | 25 ++++-- .../src/views/WorkOrders/WorkOrdersTable.tsx | 83 +++++++++++-------- .../src/views/WorkOrders/WorkOrdersView.tsx | 2 +- 5 files changed, 73 insertions(+), 44 deletions(-) diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 42003522..4060419a 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -99,6 +99,8 @@ export const AppLayout = ({ component="main" sx={(theme: Theme) => ({ flexGrow: 1, + flexShrink: 1, + minWidth: 0, p: 3, mt: 8, ...theme.mixins.toolbar, diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx index d6a3dee0..a60fd17a 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelection.tsx @@ -81,7 +81,10 @@ export const MeterSelection = ({ value={currentTabIndex} onChange={handleTabChange} aria-label="Switch between Meter List & Map" - sx={{ width: '100%', maxWidth: '100rem' }} + sx={{ + width: "100%", + maxWidth: "100rem", + }} > diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx index 30dec852..39deb5f2 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionTable.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { useDebounce } from "use-debounce"; -import { Box, Button } from "@mui/material"; +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"; @@ -128,15 +128,22 @@ export const MeterSelectionTable = ({ slotProps={{ footer: { button: hasAdminScope && ( - + + ), }, }} diff --git a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx index fe2ed589..3056766b 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersTable.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersTable.tsx @@ -30,6 +30,7 @@ import { DialogContentText, DialogTitle, IconButton, + Stack, TextField, } from "@mui/material"; import GridFooterWithButton from "../../components/GridFooterWithButton"; @@ -174,7 +175,9 @@ export default function WorkOrdersTable() { WorkOrderStatus.Open, WorkOrderStatus.Review, ]); - const workOrderList = useGetWorkOrders(workOrderFilters); + const workOrderList = useGetWorkOrders(workOrderFilters, { + refetchInterval: false, + }); const updateWorkOrder = useUpdateWorkOrder(); const deleteWorkOrder = useDeleteWorkOrder(() => console.log("Work order deleted"), @@ -217,14 +220,6 @@ export default function WorkOrdersTable() { initialFilter = [{ field: "status", operator: "not", value: "Closed" }]; } - //Refresh work order list once a minute - useEffect(() => { - const interval = setInterval(() => { - workOrderList.refetch(); - }, 60000); - return () => clearInterval(interval); - }, [workOrderList]); - //Update list of work orders if technician level to only show open and review. //useEffect prevents this from running on every render useEffect(() => { @@ -279,25 +274,30 @@ export default function WorkOrdersTable() { const handleNewWorkOrder = (newWorkOrder: NewWorkOrder) => { createWorkOrder.mutateAsync(newWorkOrder).then(() => { - //Get the updated rows workOrderList.refetch(); }); }; - // Define the columns for the table const columns: GridColDef[] = [ - { field: "work_order_id", headerName: "ID", width: 50 }, //Note next line... for some reason this value comes in from the API as a string, not a date + { + field: "work_order_id", + headerName: "ID", + flex: 1, + minWidth: 50 + }, { field: "date_created", headerName: "Date", - width: 100, + flex: 1, + minWidth: 100, valueGetter: (value) => new Date(value), valueFormatter: (value: Date) => value.toLocaleDateString(), }, { field: "meter_serial", headerName: "Meter", - width: 100, + flex: 1, + minWidth: 100, renderCell: (params) => { return ( { const activities = (params.value as MeterActivity[]) ?? []; const links = activities.map((activity, index) => ( @@ -364,7 +369,8 @@ export default function WorkOrdersTable() { { field: "assigned_user_id", headerName: "Technician Assigned", - width: 200, + flex: 2, + minWidth: 200, valueGetter: (id: number) => getUserFromID(id), type: "singleSelect", valueOptions: userList.data?.map((user) => user.full_name) ?? [], @@ -373,7 +379,8 @@ export default function WorkOrdersTable() { { field: "location_name", headerName: "Location Name", - width: 200, + flex: 2, + minWidth: 200, renderCell: (params) => { const activities = params.row.associated_activities ?? []; return activities.length > 0 ? activities[0].location_name : ""; @@ -382,7 +389,8 @@ export default function WorkOrdersTable() { { field: "water_users", headerName: "Water Users", - width: 200, + flex: 2, + minWidth: 200, renderCell: (params) => { const activities = params.row.associated_activities ?? []; return activities.length > 0 && activities[0].water_users @@ -393,7 +401,8 @@ export default function WorkOrdersTable() { { field: "actions", headerName: "Actions", - width: 100, + flex: 1, + minWidth: 100, sortable: false, renderCell: (params: GridRenderCellParams) => { const isOpen = params.row.status === "Open"; @@ -440,12 +449,13 @@ export default function WorkOrdersTable() { ]; return ( -
+ "auto"} getRowId={(row) => row.work_order_id} columns={columns} + disableColumnResize={false} initialState={{ columns: { columnVisibilityModel: { @@ -463,15 +473,22 @@ export default function WorkOrdersTable() { slotProps={{ footer: { button: hasAdminScope && ( - + + ), }, }} @@ -481,6 +498,6 @@ export default function WorkOrdersTable() { closeNewWorkOrderModal={() => setIsNewWorkOrderModalOpen(false)} submitNewWorkOrder={handleNewWorkOrder} /> -
+
); } diff --git a/frontend/src/views/WorkOrders/WorkOrdersView.tsx b/frontend/src/views/WorkOrders/WorkOrdersView.tsx index ba662d53..c3ad9394 100644 --- a/frontend/src/views/WorkOrders/WorkOrdersView.tsx +++ b/frontend/src/views/WorkOrders/WorkOrdersView.tsx @@ -7,7 +7,7 @@ import { CustomCardHeader } from "../../components/CustomCardHeader"; export default function WorkOrdersView() { return ( - + Date: Fri, 29 Aug 2025 16:30:29 -0500 Subject: [PATCH 130/146] [ActivitiesView] Fix broken styles on this view --- .../src/views/Activities/ActivitiesView.tsx | 8 +- .../MeterActivityEntry/MeterActivityEntry.tsx | 96 +++++++------------ 2 files changed, 36 insertions(+), 68 deletions(-) diff --git a/frontend/src/views/Activities/ActivitiesView.tsx b/frontend/src/views/Activities/ActivitiesView.tsx index a5e08da6..c538847f 100644 --- a/frontend/src/views/Activities/ActivitiesView.tsx +++ b/frontend/src/views/Activities/ActivitiesView.tsx @@ -1,4 +1,4 @@ -import { Grid, CardContent, Card } from "@mui/material"; +import { CardContent, Card } from "@mui/material"; import MeterActivityEntry from "./MeterActivityEntry/MeterActivityEntry"; import { Construction } from "@mui/icons-material"; import { BackgroundBox } from "../../components/BackgroundBox"; @@ -15,11 +15,7 @@ export const ActivitiesView = () => { - - - - - + diff --git a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx index e33d19f0..80487f33 100644 --- a/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx +++ b/frontend/src/views/Activities/MeterActivityEntry/MeterActivityEntry.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useState } from "react"; -import { Alert, Button, Grid } from "@mui/material"; +import { Alert, Box, Button, Stack, Typography } from "@mui/material"; import { useSnackbar } from "notistack"; import { useForm, SubmitHandler } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -79,17 +79,17 @@ export default function MeterActivityEntry() { setHasMeterActivityConflict( (meterDetails.data?.status.status_name == "Installed" && watch("activity_details.activity_type")?.name == - ActivityType.Install) || - (meterDetails.data?.status.status_name != "Installed" && - watch("activity_details.activity_type")?.name == - ActivityType.Uninstall), + ActivityType.Install) || + (meterDetails.data?.status.status_name != "Installed" && + watch("activity_details.activity_type")?.name == + ActivityType.Uninstall), ); }, [meterDetails.data, watch("activity_details.activity_type")?.name]); useEffect(() => { setIsMeterAndActivitySelected( watch("activity_details.selected_meter") != null && - watch("activity_details.activity_type") != null, + watch("activity_details.activity_type") != null, ); }, [ watch("activity_details.selected_meter"), @@ -115,48 +115,20 @@ export default function MeterActivityEntry() { const hasErrors = (errors: any) => Object.keys(errors).length > 0; return ( - <> - + + + {!hasMeterActivityConflict && isMeterAndActivitySelected ? ( - <> - - - - - - + + + + + + + + {hasErrors(errors) ? ( - + Please correct any errors before submission. ) : ( @@ -164,27 +136,27 @@ export default function MeterActivityEntry() { variant="contained" type="submit" onClick={handleSubmit(onSubmit)} + sx={{ width: { xs: "100%", sm: "auto" } }} > Submit )} - - +
+ ) : ( - - - {hasMeterActivityConflict ? ( -

- You cannot install a meter that is already installed, or - uninstall a meter that is not currently installed. Please choose - a different activity or meter. -

- ) : ( -

Please select a meter and activity to begin.

- )} -
-
+ + + {hasMeterActivityConflict + ? "You cannot install a meter that is already installed, or uninstall a meter that is not currently installed. Please choose a different activity or meter." + : "Please select a meter and activity to begin."} + + )} - + ); } From 8782aacdacf9669c04ce38a481583eee2b7c9ffc Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 09:57:38 -0500 Subject: [PATCH 131/146] [Chlorides & MonitoringWells Views] Fix UI on smaller devices --- .../src/views/Chlorides/ChloridesPlot.tsx | 80 ++++++++++--------- .../src/views/Chlorides/ChloridesTable.tsx | 13 ++- .../src/views/Chlorides/ChloridesView.tsx | 69 +++++++++------- .../MonitoringWells/MonitoringWellsPlot.tsx | 76 +++++++++--------- .../MonitoringWells/MonitoringWellsTable.tsx | 12 ++- .../MonitoringWells/MonitoringWellsView.tsx | 70 +++++++++------- 6 files changed, 178 insertions(+), 142 deletions(-) diff --git a/frontend/src/views/Chlorides/ChloridesPlot.tsx b/frontend/src/views/Chlorides/ChloridesPlot.tsx index 3529fe41..aec92f96 100644 --- a/frontend/src/views/Chlorides/ChloridesPlot.tsx +++ b/frontend/src/views/Chlorides/ChloridesPlot.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Box, Typography } from "@mui/material"; +import { Box, CircularProgress, Typography } from "@mui/material"; import Plot from "react-plotly.js"; import { Data } from "plotly.js"; @@ -12,22 +12,6 @@ export const ChloridesPlot = ({ manual_vals: { value: number; well: string }[]; isLoading: boolean; }) => { - if (isLoading) { - return ( - - Loading... - - ); - } - const data: Partial[] = useMemo(() => { const wellData: Record = {}; @@ -50,28 +34,46 @@ export const ChloridesPlot = ({ }, [manual_dates, manual_vals]); return ( - {/* Added margin of 5 pixels */} - + + {isLoading ? + + + + Loading plot data... + + + : + + } ); }; diff --git a/frontend/src/views/Chlorides/ChloridesTable.tsx b/frontend/src/views/Chlorides/ChloridesTable.tsx index cdc2c070..52665bda 100644 --- a/frontend/src/views/Chlorides/ChloridesTable.tsx +++ b/frontend/src/views/Chlorides/ChloridesTable.tsx @@ -1,6 +1,7 @@ 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 dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -10,7 +11,7 @@ dayjs.extend(utc); dayjs.extend(timezone); declare module "@mui/x-data-grid" { - interface FooterPropsOverrides extends Partial {} + interface FooterPropsOverrides extends Partial { } } interface FooterExtraProps { @@ -102,8 +103,14 @@ const Footer = ({ {isRegionSelected ? ( - ) : null} diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 7029531b..256c6419 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -7,7 +7,10 @@ import { InputLabel, Card, CardContent, - Typography, + Alert, + Button, + AlertTitle, + Grid, } from "@mui/material"; import { useMutation, useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -54,6 +57,7 @@ export const ChloridesView = () => { data: regions, isLoading: isLoadingRegions, error: errorRegions, + refetch: refetchRegions, } = useQuery<{ id: number; names: string[] }[], Error>({ queryKey: ["regions"], queryFn: () => @@ -184,19 +188,32 @@ export const ChloridesView = () => { {error && ( - - An error had occurred while attempting to loading data - + refetchRegions()} + > + Retry + + } + > + Error Loading Data + We couldn’t load chloride data. Please check your connection or try + again. + )} - Region - - - - setIsNewModalOpen(true)} - onMeasurementSelect={handleMeasurementSelect} - /> - - + m.timestamp) ?? []} @@ -245,16 +248,22 @@ export const ChloridesView = () => { })) ?? [] } /> - - - + + + setIsNewModalOpen(true)} + onMeasurementSelect={handleMeasurementSelect} + /> + + setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> - { - if (isLoading) { - return ( - - Loading... - - ); - } - const data: Partial[] = useMemo( () => [ { @@ -54,26 +38,44 @@ export const MonitoringWellsPlot = ({ ); return ( - - + + {isLoading ? + + + + Loading plot data... + + + : + + } ); }; diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx index 8b19ce89..4ae7dbe4 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsTable.tsx @@ -1,6 +1,7 @@ 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 dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -110,8 +111,15 @@ const Footer = ({ arrow > - diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 8b471b95..04676556 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -1,6 +1,5 @@ import { useId, useState, useMemo } from "react"; import { - Box, FormControl, Select, MenuItem, @@ -10,6 +9,10 @@ import { Typography, ListSubheader, useTheme, + Grid, + Alert, + Button, + AlertTitle, } from "@mui/material"; import { useQuery } from "react-query"; import { useAuthUser } from "react-auth-kit"; @@ -164,19 +167,32 @@ export const MonitoringWellsView = () => { {error && ( - - An error had occurred while attempting to loading data - + monitoredWellsQuery.refetch()} + > + Retry + + } + > + Error Loading Data + We couldn’t load monitoring wells. Please check your connection or try + again. + )} - Site - - - - well.id == wellId)} - isWellSelected={!!wellId} - onOpenModal={() => setIsNewModalOpen(true)} - onMeasurementSelect={handleMeasurementSelect} - /> - - + m.timestamp)} @@ -275,15 +276,22 @@ export const MonitoringWellsView = () => { logger_dates={(Array.isArray(st2Measurements) ? st2Measurements : []).map((m) => m.resultTime)} logger_vals={(Array.isArray(st2Measurements) ? st2Measurements : []).map((m) => m.result)} /> - - - + + + well.id == wellId)} + isWellSelected={!!wellId} + onOpenModal={() => setIsNewModalOpen(true)} + onMeasurementSelect={handleMeasurementSelect} + /> + + setIsNewModalOpen(false)} handleSubmitNewMeasurement={handleSubmitNewMeasurement} /> - setIsUpdateModalOpen(false)} From 5d819985863f5b453f13e80d241fcc4e28205ad7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 13:28:55 -0500 Subject: [PATCH 132/146] [frontend] Refactor frontend pages & add settings page --- frontend/src/App.tsx | 23 +- frontend/src/AppLayout.tsx | 11 +- frontend/src/components/Topbar.tsx | 42 +-- frontend/src/sidenav.tsx | 8 +- .../src/views/Chlorides/ChloridesView.tsx | 4 +- frontend/src/{ => views}/Home.tsx | 10 +- frontend/src/{login.tsx => views/Login.tsx} | 11 +- .../MonitoringWells/MonitoringWellsView.tsx | 5 +- frontend/src/views/Settings.tsx | 252 ++++++++++++++++++ .../WellManagement/WellManagementView.tsx | 9 +- .../WellManagement/WellSelectionTable.tsx | 55 ++-- .../src/views/WellManagement/WellsTable.tsx | 25 +- frontend/src/views/index.ts | 3 + 13 files changed, 374 insertions(+), 84 deletions(-) rename frontend/src/{ => views}/Home.tsx (92%) rename frontend/src/{login.tsx => views/Login.tsx} (93%) create mode 100644 frontend/src/views/Settings.tsx create mode 100644 frontend/src/views/index.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7fe3dd0..24bdd003 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,11 @@ 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 { MonitoringWellsView } from "./views/MonitoringWells/MonitoringWellsView"; import { ActivitiesView } from "./views/Activities/ActivitiesView"; import { MetersView } from "./views/Meters/MetersView"; @@ -16,8 +21,6 @@ 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 { Home } from "./Home"; -import Login from "./login"; import { ChloridesView } from "./views/Chlorides/ChloridesView"; import { ReportsView } from "./views/Reports"; import { WorkOrdersReportView } from "./views/Reports/WorkOrders"; @@ -54,6 +57,16 @@ export const App = () => { > + } + requiredScopes={[]} + setErrorMessage={setErrorMessage} + /> + } + /> } @@ -62,11 +75,11 @@ export const App = () => { /> } /> } - requiredScopes={[]} + pageComponent={} + requiredScopes={["read"]} setErrorMessage={setErrorMessage} /> } diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 4060419a..9c9c85ae 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -7,7 +7,7 @@ import { SecurityScope } from "./interfaces"; import Topbar from "./components/Topbar"; import Sidenav from "./sidenav"; -const drawerWidth = 300; +const drawerWidth = 250; export const AppLayout = ({ pageComponent, @@ -73,6 +73,7 @@ export const AppLayout = ({ return ( setDrawerOpen(!drawerOpen)} sx={(theme: Theme) => ({ zIndex: theme.zIndex.drawer + 1, @@ -80,14 +81,6 @@ export const AppLayout = ({ easing: theme.transitions.easing.sharp, duration: theme.transitions.duration.leavingScreen, }), - ...(drawerOpen && { - width: `calc(100% - ${drawerWidth}px)`, - ml: `${drawerWidth}px`, - transition: theme.transitions.create(["margin", "width"], { - easing: theme.transitions.easing.easeOut, - duration: theme.transitions.duration.enteringScreen, - }), - }), })} /> void; sx?: any }) { +export default function Topbar({ open, onMenuClick, sx }: { open: boolean, onMenuClick: () => void; sx?: any }) { const navigate = useNavigate(); const signOut = useSignOut(); const authUser = useAuthUser(); @@ -67,25 +67,27 @@ export default function Topbar({ onMenuClick, sx }: { onMenuClick: () => void; s > - navigate("/")} - > - Meter Manager - + {!open ? + navigate("/")} + > + Meter Manager + + : null} {isLoggedIn ? ( diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index 26e2ad0a..9e345dc4 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -96,10 +96,10 @@ export default function Sidenav({ fontWeight: "bold", ml: 2, fontSize: { - sx: "1.25rem", - md: "1.5rem", - lg: "1.625rem", - xl: "1.75rem", + sx: "1rem", + md: "1.25rem", + lg: "1.5rem", + xl: "1.625remrem", }, }} onClick={() => navigate("/")} diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 256c6419..9f7e1b8c 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -237,7 +237,7 @@ export const ChloridesView = () => { spacing={2} sx={{ mt: "1rem" }} > - + m.timestamp) ?? []} @@ -249,7 +249,7 @@ export const ChloridesView = () => { } /> - + { const versionHistory = [ diff --git a/frontend/src/login.tsx b/frontend/src/views/Login.tsx similarity index 93% rename from frontend/src/login.tsx rename to frontend/src/views/Login.tsx index faaeefe4..75a15d17 100644 --- a/frontend/src/login.tsx +++ b/frontend/src/views/Login.tsx @@ -13,11 +13,11 @@ import { } from "@mui/material"; import LoginIcon from '@mui/icons-material/Login'; 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"; -const Login = () => { +export const Login = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); @@ -65,7 +65,8 @@ const Login = () => { ) { localStorage.setItem("_auth", data.access_token); localStorage.setItem("loggedIn", "true"); - navigate("/"); + const savedRedirect = localStorage.getItem("redirectPage") ?? "/"; + navigate(savedRedirect); } else { setError("Invalid username or password. Please try again."); } diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 04676556..038b60e5 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -6,7 +6,6 @@ import { InputLabel, Card, CardContent, - Typography, ListSubheader, useTheme, Grid, @@ -268,7 +267,7 @@ export const MonitoringWellsView = () => { spacing={2} sx={{ mt: "1rem" }} > - + m.timestamp)} @@ -277,7 +276,7 @@ export const MonitoringWellsView = () => { logger_vals={(Array.isArray(st2Measurements) ? st2Measurements : []).map((m) => m.result)} /> - + well.id == wellId)} diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx new file mode 100644 index 00000000..bd6f4fac --- /dev/null +++ b/frontend/src/views/Settings.tsx @@ -0,0 +1,252 @@ +import * as yup from 'yup'; +import { yupResolver } from "@hookform/resolvers/yup"; +import { useForm, Controller } from "react-hook-form"; +import { + Card, + CardContent, + Divider, + Typography, + Box, + Button, + MenuItem, + TextField, + Grid, + Alert, + ListItemIcon, +} from "@mui/material"; +import SettingsIcon from "@mui/icons-material/Settings"; +import { useAuthUser } from "react-auth-kit"; +import { useEffect, useMemo, useState } from "react"; +import HomeIcon from "@mui/icons-material/Home"; +import { + Build, + FormatListBulletedOutlined, + ScreenshotMonitor, + Construction, + MonitorHeart, + Plumbing, + Assessment, + Science +} from '@mui/icons-material'; +import { BackgroundBox, CustomCardHeader } from "../components"; + +const redirectOptions = [ + { value: "/", label: "Home", icon: }, + { value: "/workorders", label: "Work Orders", icon: }, + { value: "/meters", label: "Meter Information", icon: }, + { value: "/activities", label: "Activities", icon: }, + { value: "/wells", label: "Monitoring Wells", icon: }, + { value: "/wellmanagement", label: "Manage Wells", icon: }, + { value: "/reports", label: "Reports", icon: }, + { value: "/reports/wells", label: "Monitoring Wells Report", icon: }, + { value: "/reports/maintenance", label: "Maintenance Report", icon: }, + { value: "/reports/partsused", label: "Parts Used Report", icon: }, + { value: "/reports/chlorides", label: "Chlorides Report", icon: }, +]; + +const schema = yup.object().shape({ + redirectPage: yup.string().optional(), + currentPassword: yup.string().optional(), + newPassword: yup.string().optional(), + confirmPassword: yup + .string() + .oneOf([yup.ref("newPassword"), ""], "Passwords must match"), +}); + +const FALLBACK_REDIRECT = "/"; + +export const Settings = () => { + const authUser = useAuthUser(); + const [savedMessage, setSavedMessage] = useState(""); + + // always read the latest from localStorage + const defaultValues = useMemo(() => { + const stored = localStorage.getItem("redirectPage"); + return { + redirectPage: stored ?? FALLBACK_REDIRECT, + currentPassword: "", + newPassword: "", + confirmPassword: "", + }; + }, []); + + const { + control, + handleSubmit, + watch, + formState: { errors, isValid }, + } = useForm({ + resolver: yupResolver(schema), + mode: "onChange", + defaultValues, + }); + + // Auto-save redirectPage when it changes + const redirectPage = watch("redirectPage"); + useEffect(() => { + if (redirectPage) { + localStorage.setItem("redirectPage", redirectPage); + setSavedMessage("Redirect preference saved locally (not synced across devices)."); + } + }, [redirectPage]); + + const onSubmit = (data: any) => { + if (data.newPassword && data.currentPassword) { + // password reset API call would go here + console.log("Password reset request:", data); + setSavedMessage("Password reset request submitted."); + } + }; + + const user = authUser(); + + return ( + + + + + {/* User Info */} + + + User Information + + + + + + Full Name: {user?.full_name ?? "N/A"} + + + + + Email: {user?.email ?? "N/A"} + + + + + Username: {user?.username ?? "N/A"} + + + + + Role: {user?.user_role?.name ?? "N/A"} + + + + + Active: {!user?.disabled ? "Yes" : "No"} + + + + + + + +
+ + Preferences + + ( + + {redirectOptions.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + )} + /> + + + Password Reset + + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + + + + + {savedMessage && ( + + {savedMessage} + + )} +
+
+
+ ); +}; + diff --git a/frontend/src/views/WellManagement/WellManagementView.tsx b/frontend/src/views/WellManagement/WellManagementView.tsx index 4c44a38f..9927a275 100644 --- a/frontend/src/views/WellManagement/WellManagementView.tsx +++ b/frontend/src/views/WellManagement/WellManagementView.tsx @@ -15,14 +15,17 @@ export default function WellManagementView() { return ( - - + + - + row.meters.map((meter) => meter.water_users).join(", "), @@ -60,19 +71,22 @@ export default function WellSelectionTable({ { field: "use_type", headerName: "Use Type", - width: 150, + flex: 1, + minWidth: 150, valueGetter: (_, row) => row.use_type?.use_type, }, { field: "location", headerName: "TRSS", - width: 150, + flex: 1, + minWidth: 150, valueGetter: (_, row) => row.location?.trss, }, { field: "meters", headerName: "Meters", - width: 200, + flex: 2, + minWidth: 200, sortable: false, renderCell: (params) => { const meters = params.value as Well["meters"]; @@ -110,7 +124,7 @@ export default function WellSelectionTable({ // Ternaries in sorting make sure that the view defaults to showing the backend's defaults return ( - + setWellAddMode(true)} - disabled={!hasAdminScope} + button: hasAdminScope && ( + - - Add a New Well - + + ), }, }} diff --git a/frontend/src/views/WellManagement/WellsTable.tsx b/frontend/src/views/WellManagement/WellsTable.tsx index a6d1dd2a..72d19fd2 100644 --- a/frontend/src/views/WellManagement/WellsTable.tsx +++ b/frontend/src/views/WellManagement/WellsTable.tsx @@ -7,9 +7,10 @@ import { Tab, Tabs, Box, + InputAdornment, } from "@mui/material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; -import SearchIcon from "@mui/icons-material/Search"; +import { Search } from "@mui/icons-material"; import TabPanel from "../../components/TabPanel"; import WellSelectionTable from "./WellSelectionTable"; import WellSelectionMap from "./WellSelectionMap"; @@ -34,26 +35,28 @@ export const WellsTable = ({ icon={FormatListBulletedOutlinedIcon} /> - - + + - + - {" "} -  Search Wells - - } + sx={{ m: 0, pl: 2, width: '100%', maxWidth: '75rem' }} + placeholder="Search Wells..." variant="outlined" size="small" value={wellSearchQuery} onChange={(event: any) => setWellSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> diff --git a/frontend/src/views/index.ts b/frontend/src/views/index.ts new file mode 100644 index 00000000..cde7f1e1 --- /dev/null +++ b/frontend/src/views/index.ts @@ -0,0 +1,3 @@ +export * from './Home' +export * from './Login' +export * from './Settings' From efa7483159f5f6cf708fb92a945024d53c402bcf Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 14:23:09 -0500 Subject: [PATCH 133/146] [maintenance] Fix TRSS bug --- api/routes/maintenance.py | 47 +++++++++---------- .../src/views/Reports/Maintenance/index.tsx | 45 ++++++++++++++++-- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/api/routes/maintenance.py b/api/routes/maintenance.py index 6078cf9a..104fb77e 100644 --- a/api/routes/maintenance.py +++ b/api/routes/maintenance.py @@ -44,9 +44,10 @@ class MeterSummary(BaseModel): class MaintenanceRow(BaseModel): date_time: datetime technician: str + meter: str + trss: str number_of_repairs: int number_of_pms: int - meter: str class MaintenanceSummaryResponse(BaseModel): @@ -93,27 +94,22 @@ def get_maintenance_summary( matching_meter_ids = None if trss: try: - parts = list(map(int, trss.strip().split("."))) - if len(parts) >= 4: - township, range_, section, quarter = parts[:4] - - location_subq = ( - db.query(Locations.id) - .filter( - Locations.township == township, - Locations.range == range_, - Locations.section == section, - Locations.quarter == quarter, - ) + # normalize input (strip spaces) + trss_str = trss.strip() + + location_ids = ( + db.query(Locations.id) + .filter(Locations.trss.like(f"{trss_str}%")) + .all() + ) + location_ids = [loc_id for (loc_id,) in location_ids] + + if location_ids: + meter_subq = ( + db.query(Meters.id) + .filter(Meters.location_id.in_(location_ids)) ) - location_ids = [loc_id for (loc_id,) in location_subq.all()] - - if location_ids: - meter_subq = ( - db.query(Meters.id) - .filter(Meters.location_id.in_(location_ids)) - ) - matching_meter_ids = [m_id for (m_id,) in meter_subq.all()] + matching_meter_ids = [m_id for (m_id,) in meter_subq.all()] except Exception: pass # Ignore invalid TRSS input silently @@ -123,7 +119,8 @@ def get_maintenance_summary( MeterActivities.timestamp_start.label("date_time"), Users.full_name.label("technician"), Meters.serial_number.label("meter"), - ActivityTypeLU.name.label("activity_type") + ActivityTypeLU.name.label("activity_type"), + Locations.trss.label("trss") ) .join(Users, Users.id == MeterActivities.submitting_user_id) .join(Meters, Meters.id == MeterActivities.meter_id) @@ -131,6 +128,7 @@ def get_maintenance_summary( ActivityTypeLU, ActivityTypeLU.id == MeterActivities.activity_type_id ) + .join(Locations, Locations.id == Meters.location_id, isouter=True) .filter(MeterActivities.timestamp_start >= from_date) .filter(MeterActivities.timestamp_start <= to_date) ) @@ -158,7 +156,7 @@ def get_maintenance_summary( grouped_rows = defaultdict(lambda: {"number_of_repairs": 0, "number_of_pms": 0}) for row in base_query: - key = (row.date_time, row.technician, row.meter) + key = (row.date_time, row.technician, row.meter, row.trss) if row.activity_type == "Repair": repairs_by_meter[row.meter] += 1 grouped_rows[key]["number_of_repairs"] += 1 @@ -170,11 +168,12 @@ def get_maintenance_summary( pms_result = [{"meter": meter, "count": count} for meter, count in pms_by_meter.items()] table_rows = [] - for (date_time, technician, meter), counts in grouped_rows.items(): + for (date_time, technician, meter, trss), counts in grouped_rows.items(): table_rows.append({ "date_time": date_time, "technician": technician, "meter": meter, + "trss": trss or "", "number_of_repairs": counts["number_of_repairs"], "number_of_pms": counts["number_of_pms"], }) diff --git a/frontend/src/views/Reports/Maintenance/index.tsx b/frontend/src/views/Reports/Maintenance/index.tsx index b34e1217..c6094ded 100644 --- a/frontend/src/views/Reports/Maintenance/index.tsx +++ b/frontend/src/views/Reports/Maintenance/index.tsx @@ -27,7 +27,13 @@ import ControlledTextbox from "../../../components/RHControlled/ControlledTextbo import { useAuthHeader } from "react-auth-kit"; import { API_URL } from "../../../config"; import { PieChart } from "@mui/x-charts"; -import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { + DataGrid, + GridColDef, + GridValueGetter, + GridValueFormatter +} from "@mui/x-data-grid"; + interface User { full_name: string; @@ -47,7 +53,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)); }), @@ -78,7 +84,7 @@ const size = { export const MaintenanceReportView = () => { const authHeader = useAuthHeader(); const techiciansQuery = useQuery({ - queryKey: ["Repairs", "report", "techicians"], + queryKey: ["users"], queryFn: async () => { const response = await fetch(`${API_URL}/users`, { headers: { Authorization: authHeader() }, @@ -91,6 +97,9 @@ export const MaintenanceReportView = () => { }, staleTime: 1000 * 60 * 60 * 24, // 24 hours cacheTime: 1000 * 60 * 60 * 24, // cache in memory for 24 hours + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, }); const { control, reset, setValue, watch } = useForm({ @@ -109,7 +118,15 @@ export const MaintenanceReportView = () => { }, [techiciansQuery.data]); const dataQuery = useQuery({ - queryKey: ["Inventory", "report", "maintenance", from, to, technicians], + queryKey: [ + "maintenance", + { + from: from?.format("YYYY-MM"), + to: to?.format("YYYY-MM"), + trss: trss ?? "", + technicians: technicians?.map((t) => t.id) ?? [], + }, + ], queryFn: async () => { const queryParams = new URLSearchParams(); queryParams.set("from_month", from?.format("YYYY-MM")); @@ -168,7 +185,20 @@ export const MaintenanceReportView = () => { }, [dataQuery.data]); const columns: GridColDef[] = [ - { field: "date_time", headerName: "Date / Time", flex: 1 }, + { + field: "date_time", + headerName: "Date / Time", + type: "dateTime", + flex: 1, + valueGetter: ((value: any) => { + return value ? new Date(value as string) : new Date(); + }) as GridValueGetter, + valueFormatter: ((value) => { + if (!value) return ""; + const date = value as Date; + return date.toLocaleString(); + }) as GridValueFormatter, + }, { field: "technician", headerName: "Technician", flex: 1 }, { field: "number_of_repairs", @@ -187,6 +217,11 @@ export const MaintenanceReportView = () => { headerName: "Meter", flex: 1, }, + { + field: "trss", + headerName: "TRSS", + flex: 1, + }, ]; const downloadMaintenancePDFMutation = useMutation({ From b6bb39588ebd4939e94a9ced3ab357d0ee5fdd3d Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 14:34:18 -0500 Subject: [PATCH 134/146] [frontend] Rm broken imports --- frontend/src/views/Chlorides/ChloridesView.tsx | 1 - frontend/src/views/Reports/index.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index 9f7e1b8c..f94e7146 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -1,6 +1,5 @@ import { useId, useState } from "react"; import { - Box, FormControl, Select, MenuItem, diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index c86b921c..c7b8c69c 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -1,9 +1,7 @@ import { Assessment, Build, - FormatListBulletedOutlined, MonitorHeart, - People, Plumbing, Science, } from "@mui/icons-material"; From 8aab8308dde2364eda6ca3ad11dd21ce287f12b6 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 15:46:16 -0500 Subject: [PATCH 135/146] [sidenav] Update drawer to be more responsive --- frontend/src/sidenav.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/sidenav.tsx b/frontend/src/sidenav.tsx index 9e345dc4..dd73a22c 100644 --- a/frontend/src/sidenav.tsx +++ b/frontend/src/sidenav.tsx @@ -65,9 +65,13 @@ export default function Sidenav({ return ( Date: Tue, 2 Sep 2025 16:22:46 -0500 Subject: [PATCH 136/146] [Settings] Commented out password reset for now --- frontend/src/views/Settings.tsx | 106 ++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 24 deletions(-) diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index bd6f4fac..0c22bfe1 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -7,12 +7,13 @@ import { Divider, Typography, Box, - Button, + // Button, MenuItem, TextField, Grid, Alert, ListItemIcon, + Chip, } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; import { useAuthUser } from "react-auth-kit"; @@ -55,6 +56,28 @@ const schema = yup.object().shape({ const FALLBACK_REDIRECT = "/"; +const RoleChip = ({ role }: { role: string }) => { + switch (role) { + case "Admin": { + return ; + } + case "Technician": { + return ; + } + default: { + return ; + } + } +} + +const IsActiveChip = ({ active }: { active: boolean }) => { + return active ? ( + + ) : ( + + ); +} + export const Settings = () => { const authUser = useAuthUser(); const [savedMessage, setSavedMessage] = useState(""); @@ -74,7 +97,7 @@ export const Settings = () => { control, handleSubmit, watch, - formState: { errors, isValid }, + // formState: { errors, isValid }, } = useForm({ resolver: yupResolver(schema), mode: "onChange", @@ -112,38 +135,74 @@ export const Settings = () => { - + - Full Name: {user?.full_name ?? "N/A"} + Full Name:{" "} + + {user?.full_name ?? "N/A"} + - + - Email: {user?.email ?? "N/A"} + Email:{" "} + + {user?.email ?? "N/A"} + - + - Username: {user?.username ?? "N/A"} + Username:{" "} + + {user?.username ?? "N/A"} + - + - Role: {user?.user_role?.name ?? "N/A"} + Role: + - + - Active: {!user?.disabled ? "Yes" : "No"} + Active: + - -
- + Preferences { )} /> - - + {/* + + Password Reset - + ( { )} /> - + ( { )} /> - + ( { + */} {savedMessage && ( From 67b8963b30b9cde44772f71a448cf487f3fb242f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 17:01:18 -0500 Subject: [PATCH 137/146] [MonitoringWells] Patch styles on mobile --- frontend/package-lock.json | 79 ++++++++-------- frontend/package.json | 2 +- .../src/views/Chlorides/ChloridesView.tsx | 1 + .../MonitoringWells/MonitoringWellsView.tsx | 1 + .../views/Reports/MonitoringWells/index.tsx | 93 +++++++++---------- frontend/src/views/Settings.tsx | 1 + 6 files changed, 88 insertions(+), 89 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a7518105..9ea3c7e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,7 @@ "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.10.6", "@mui/material": "^5.15.14", - "@mui/x-charts": "^8.5.3", + "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", "dayjs": "^1.11.9", @@ -134,9 +134,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1511,12 +1511,12 @@ } }, "node_modules/@mui/types": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz", - "integrity": "sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==", + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.6.tgz", + "integrity": "sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1" + "@babel/runtime": "^7.28.3" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1558,20 +1558,21 @@ } }, "node_modules/@mui/x-charts": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-8.5.3.tgz", - "integrity": "sha512-aLU3KNA5bfKufxCPxBYx34xOn1mY5xaYGxxImEIQhL1BDnsjdkeF7b7gitL62XHpJe7ceU0nr2PbAr8msU0ZBQ==", + "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==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.6", - "@mui/utils": "^7.1.1", - "@mui/x-charts-vendor": "8.5.3", - "@mui/x-internals": "8.5.3", + "@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", "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.5.0" + "use-sync-external-store": "^1.4.0" }, "engines": { "node": ">=14.0.0" @@ -1594,12 +1595,12 @@ } }, "node_modules/@mui/x-charts-vendor": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-8.5.3.tgz", - "integrity": "sha512-H05cb0c2qfRhWLPcwtiIU8BOcKTrMNvhgmRAvJJXpmlirOA1km8dUlR71VeUvJiCthhVIHKyFkPPzFYKgHAfng==", + "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==", "license": "MIT AND ISC", "dependencies": { - "@babel/runtime": "^7.27.6", + "@babel/runtime": "^7.27.0", "@types/d3-color": "^3.1.3", "@types/d3-delaunay": "^6.0.4", "@types/d3-interpolate": "^3.0.4", @@ -1673,17 +1674,17 @@ } }, "node_modules/@mui/x-charts/node_modules/@mui/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==", + "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==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/types": "^7.4.3", - "@types/prop-types": "^15.7.14", + "@babel/runtime": "^7.28.3", + "@mui/types": "^7.4.6", + "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.0" + "react-is": "^19.1.1" }, "engines": { "node": ">=14.0.0" @@ -1703,14 +1704,13 @@ } }, "node_modules/@mui/x-charts/node_modules/@mui/x-internals": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.5.3.tgz", - "integrity": "sha512-ImCg4E3DT3XoDIZO0pNCbB7iw14N+YCFY3J1V28POwCD7P2f3HSIz4jwzM006oYxI6bqeE6LMfpdPRDW6s6dQw==", + "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.6", - "@mui/utils": "^7.1.1", - "reselect": "^5.1.1" + "@babel/runtime": "^7.27.0", + "@mui/utils": "^7.0.0" }, "engines": { "node": ">=14.0.0" @@ -1720,7 +1720,6 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, @@ -2773,9 +2772,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { @@ -7529,9 +7528,9 @@ } }, "node_modules/react-is": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", - "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.1.tgz", + "integrity": "sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==", "license": "MIT" }, "node_modules/react-leaflet": { diff --git a/frontend/package.json b/frontend/package.json index 935b4f33..6650a1e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,7 @@ "@hookform/resolvers": "^3.2.0", "@mui/icons-material": "^5.10.6", "@mui/material": "^5.15.14", - "@mui/x-charts": "^8.5.3", + "@mui/x-charts": "^8.0.0-beta.3", "@mui/x-data-grid": "^7.0.0", "@mui/x-date-pickers": "^6.10.0", "dayjs": "^1.11.9", diff --git a/frontend/src/views/Chlorides/ChloridesView.tsx b/frontend/src/views/Chlorides/ChloridesView.tsx index f94e7146..2df99a76 100644 --- a/frontend/src/views/Chlorides/ChloridesView.tsx +++ b/frontend/src/views/Chlorides/ChloridesView.tsx @@ -207,6 +207,7 @@ export const ChloridesView = () => { )} diff --git a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx index 038b60e5..66567801 100644 --- a/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx +++ b/frontend/src/views/MonitoringWells/MonitoringWellsView.tsx @@ -186,6 +186,7 @@ export const MonitoringWellsView = () => { )} diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 4076446c..933bcf36 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -35,7 +35,9 @@ 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 { + LineChart, +} from "@mui/x-charts"; import { MonitoredWell, WellMeasurementDTO } from "../../../interfaces"; import { useFetchWithAuth } from "../../../hooks"; import { separateAndSortMonitoredWells } from "../../../utils"; @@ -82,11 +84,6 @@ const defaultSchema = { comparisonYear: null, }; -const size = { - width: 1000, - height: 600, -}; - export const MonitoringWellsReportView = () => { const theme = useTheme(); const baseStyle = css` @@ -380,31 +377,31 @@ export const MonitoringWellsReportView = () => { paddingTop={2} paddingBottom={2} > - + - + - + { {...params} sx={{ minWidth: "30rem" }} label="Wells" - size="medium" + size="small" placeholder="Begin typing to search" /> ); @@ -490,8 +487,8 @@ export const MonitoringWellsReportView = () => { /> - - + + { />
- + Depth of Water over Time - { - 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: { - direction: "horizontal", - position: { - vertical: "bottom", - horizontal: "center", + + { + 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: { + direction: "horizontal", + position: { + vertical: "bottom", + horizontal: "center", + }, }, - }, - }} - {...size} - /> + }} + sx={{ width: "100%", height: "100%" }} + /> + - + { }} /> - - - - + +
diff --git a/frontend/src/views/Settings.tsx b/frontend/src/views/Settings.tsx index 0c22bfe1..253fe3c6 100644 --- a/frontend/src/views/Settings.tsx +++ b/frontend/src/views/Settings.tsx @@ -211,6 +211,7 @@ export const Settings = () => { render={({ field }) => ( Date: Tue, 2 Sep 2025 18:41:08 -0500 Subject: [PATCH 138/146] [views/Reports] Fix styling on reports for tablet & mobile --- .../src/views/Reports/Chlorides/index.tsx | 14 +-- .../src/views/Reports/Maintenance/index.tsx | 118 +++++++++--------- .../views/Reports/MonitoringWells/index.tsx | 7 +- .../src/views/Reports/PartsUsed/index.tsx | 36 +++--- 4 files changed, 89 insertions(+), 86 deletions(-) diff --git a/frontend/src/views/Reports/Chlorides/index.tsx b/frontend/src/views/Reports/Chlorides/index.tsx index 6aa5fb10..156bc0ea 100644 --- a/frontend/src/views/Reports/Chlorides/index.tsx +++ b/frontend/src/views/Reports/Chlorides/index.tsx @@ -197,28 +197,28 @@ export const ChloridesReportView = () => { container justifyContent="flex-start" alignContent="center" - gap={2} + spacing={2} paddingTop={2} paddingBottom={2} > - + - + { const authHeader = useAuthHeader(); const techiciansQuery = useQuery({ @@ -305,45 +299,43 @@ export const MaintenanceReportView = () => { - + - + - + - + { {...params} label="Technician(s)" sx={{ minWidth: "15rem" }} - size="medium" + size="small" placeholder="Begin typing to search" /> ); @@ -394,59 +386,75 @@ export const MaintenanceReportView = () => { /> - - - - Number of Repairs + + + + + Number of Repairs + {numberOfRepairsPieChartData?.length ? `: ${numberOfRepairsPieChartData?.length}` : null} + 10 ? 0 : 10, + paddingAngle: numberOfRepairsPieChartData?.length > 10 ? 0 : 1, + cornerRadius: numberOfRepairsPieChartData?.length > 10 ? 0 : 10, }, ]} - slotProps={{ - legend: { - direction: "horizontal", - position: { - vertical: "bottom", - horizontal: "center", - }, - }, + hideLegend={true} + sx={{ + width: "100%", + height: "100%", }} - {...size} /> - - + + + + + + Number of Preventative Maintenances + {numberOfPMsPieChartData?.length ? `: ${numberOfPMsPieChartData?.length}` : null} 10 ? 0 : 10, + paddingAngle: numberOfPMsPieChartData?.length > 10 ? 0 : 1, + cornerRadius: numberOfPMsPieChartData?.length > 10 ? 0 : 10, }, ]} - slotProps={{ - legend: { - direction: "horizontal", - position: { - vertical: "bottom", - horizontal: "center", - }, - }, + hideLegend={true} + sx={{ + width: "100%", + height: "100%", }} - {...size} /> - + - + { }} /> - - - - + +
diff --git a/frontend/src/views/Reports/MonitoringWells/index.tsx b/frontend/src/views/Reports/MonitoringWells/index.tsx index 933bcf36..445223c7 100644 --- a/frontend/src/views/Reports/MonitoringWells/index.tsx +++ b/frontend/src/views/Reports/MonitoringWells/index.tsx @@ -373,11 +373,11 @@ export const MonitoringWellsReportView = () => { container justifyContent="flex-start" alignContent="center" - gap={2} + spacing={2} paddingTop={2} paddingBottom={2} > - + { format="YYYY MMMM" /> - + { return ( { container justifyContent="flex-start" alignContent="center" - gap={2} + spacing={2} padding={2} > - + - + - + { getOptionLabel={(option: any) => option.type.name} /> - + { renderInput={(params) => ( @@ -400,7 +400,7 @@ export const PartsUsedReportView = () => { }} /> - + { /> - + { }} /> - - - - + +
From 036570a4d3ee70150e75aaafc7487cc9917d5ecf Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 20:21:45 -0500 Subject: [PATCH 139/146] [Parts & UserManagement] Update pages to be tablet friendly --- frontend/src/views/Parts/MeterTypesTable.tsx | 112 +++++++++------- frontend/src/views/Parts/PartsTable.tsx | 117 +++++++++-------- frontend/src/views/Parts/PartsView.tsx | 62 ++++----- .../views/UserManagement/PermissionsTable.tsx | 90 +++++++------ .../src/views/UserManagement/RolesTable.tsx | 102 ++++++++------- .../UserManagement/UserManagementView.tsx | 72 ++++------ .../src/views/UserManagement/UsersTable.tsx | 123 +++++++++--------- 7 files changed, 346 insertions(+), 332 deletions(-) diff --git a/frontend/src/views/Parts/MeterTypesTable.tsx b/frontend/src/views/Parts/MeterTypesTable.tsx index c26764bb..b57e4621 100644 --- a/frontend/src/views/Parts/MeterTypesTable.tsx +++ b/frontend/src/views/Parts/MeterTypesTable.tsx @@ -6,12 +6,15 @@ import { CardContent, Chip, Grid, + InputAdornment, + Stack, 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 SearchIcon from "@mui/icons-material/Search"; import { MeterTypeLU } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; @@ -69,67 +72,76 @@ export const MeterTypesTable = ({ }, [meterTypeSearchQuery, meterTypes.data, inUseFilter]); return ( - + - - - + + + - {" "} -  Search Meter Types - - } + sx={{ m: 0, width: '100%', maxWidth: '75rem' }} + placeholder="Search Meter Types..." variant="outlined" size="small" value={meterTypeSearchQuery} - onChange={(event: any) => - setMeterTypeSearchQuery(event.target.value) - } - sx={{ marginBottom: "10px" }} + onChange={(event: any) => setMeterTypeSearchQuery(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - -
-
Choose Filters:
- setInUseFilter(state)} - /> -
+ + Choose Filters: + setInUseFilter(state)} + />
- { - setSelectedMeterType(selectedRow.row); - }} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: ( - - ), - }, - }} - disableColumnFilter - /> + + { + setSelectedMeterType(selectedRow.row); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + + + ), + }, + }} + disableColumnFilter + /> +
-
+
); }; diff --git a/frontend/src/views/Parts/PartsTable.tsx b/frontend/src/views/Parts/PartsTable.tsx index fb6db54f..1ed7a630 100644 --- a/frontend/src/views/Parts/PartsTable.tsx +++ b/frontend/src/views/Parts/PartsTable.tsx @@ -6,12 +6,15 @@ import { CardContent, Chip, Grid, + InputAdornment, + Stack, 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 SearchIcon from "@mui/icons-material/Search"; import { Part } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; @@ -82,70 +85,80 @@ export const PartsTable = ({ }, [partSearchQuery, partsList.data, inUseFilter, commonlyUsedFilter]); return ( - + - - - + + + - {" "} -  Search Parts - - } + sx={{ m: 0, width: '100%', maxWidth: '75rem' }} + placeholder="Search Parts..." variant="outlined" size="small" value={partSearchQuery} onChange={(event: any) => setPartSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + Choose Filters: + setInUseFilter(state)} + /> + + setCommonlyUsedFilter(state) + } /> - -
-
Choose Filters:
- setInUseFilter(state)} - /> - - setCommonlyUsedFilter(state) - } - /> -
+ + { + setSelectedPartID(selectedRow.row.id); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + + + ), + }, + }} + disableColumnFilter + />
- { - setSelectedPartID(selectedRow.row.id); - }} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: ( - - ), - }, - }} - disableColumnFilter - />
); diff --git a/frontend/src/views/Parts/PartsView.tsx b/frontend/src/views/Parts/PartsView.tsx index dd51163e..18b4d7ab 100644 --- a/frontend/src/views/Parts/PartsView.tsx +++ b/frontend/src/views/Parts/PartsView.tsx @@ -24,46 +24,32 @@ export const PartsView = () => { return ( - - - - - - - - + + + - - - - - - - + + + + + + + + - + ); }; diff --git a/frontend/src/views/UserManagement/PermissionsTable.tsx b/frontend/src/views/UserManagement/PermissionsTable.tsx index 319dffa9..0f6a66a8 100644 --- a/frontend/src/views/UserManagement/PermissionsTable.tsx +++ b/frontend/src/views/UserManagement/PermissionsTable.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { Button, Card, CardContent, Grid, TextField } from "@mui/material"; +import { Button, Card, CardContent, Grid, InputAdornment, TextField, Tooltip } from "@mui/material"; import { useGetSecurityScopes } from "../../service/ApiServiceNew"; import AddIcon from "@mui/icons-material/Add"; -import SearchIcon from "@mui/icons-material/Search"; +import { Search } from "@mui/icons-material"; import FormatListBulletedOutlinedIcon from "@mui/icons-material/FormatListBulletedOutlined"; import { SecurityScope } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; @@ -33,48 +33,60 @@ export const PermissionsTable = () => { }, [permissionSearchQuery, securityScopesList.data]); return ( - + - - - - -  Search Permissions - - } - variant="outlined" - size="small" - value={permissionSearchQuery} - onChange={(event: any) => - setPermissionSearchQuery(event.target.value) - } - sx={{ marginBottom: "10px" }} - /> + + + + setPermissionSearchQuery(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + + + ), + }, + }} + disableColumnFilter + /> + - - - Permissions must be configured by a developer - - ), - }, - }} - disableColumnFilter - /> ); diff --git a/frontend/src/views/UserManagement/RolesTable.tsx b/frontend/src/views/UserManagement/RolesTable.tsx index f2cf0f8a..6ccf9994 100644 --- a/frontend/src/views/UserManagement/RolesTable.tsx +++ b/frontend/src/views/UserManagement/RolesTable.tsx @@ -6,12 +6,13 @@ import { CardContent, Chip, Grid, + 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 SearchIcon from "@mui/icons-material/Search"; import { UserRole } from "../../interfaces"; import GridFooterWithButton from "../../components/GridFooterWithButton"; import { CustomCardHeader } from "../../components/CustomCardHeader"; @@ -65,57 +66,64 @@ export const RolesTable = ({ }, [roleSearchQuery, rolesList.data]); return ( - + - - - - {" "} -  Search Roles - - } - variant="outlined" - size="small" - value={roleSearchQuery} - onChange={(event: any) => setRoleSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} - /> + + + + setRoleSearchQuery(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + { + setSelectedRole( + rolesList.data?.find( + (role: UserRole) => role.id == selectedRow.row.id, + ), + ); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + ), + }, + }} + disableColumnFilter + /> + - { - setSelectedRole( - rolesList.data?.find( - (role: UserRole) => role.id == selectedRow.row.id, - ), - ); - }} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: ( - - ), - }, - }} - disableColumnFilter - /> ); diff --git a/frontend/src/views/UserManagement/UserManagementView.tsx b/frontend/src/views/UserManagement/UserManagementView.tsx index 63c2144f..9855882c 100644 --- a/frontend/src/views/UserManagement/UserManagementView.tsx +++ b/frontend/src/views/UserManagement/UserManagementView.tsx @@ -26,56 +26,34 @@ export const UserManagementView = () => { return ( - - - - - - - + + - - - - - - - + + - - - - + + + + + + + + - + ); }; diff --git a/frontend/src/views/UserManagement/UsersTable.tsx b/frontend/src/views/UserManagement/UsersTable.tsx index 25a1505e..ecc38d27 100644 --- a/frontend/src/views/UserManagement/UsersTable.tsx +++ b/frontend/src/views/UserManagement/UsersTable.tsx @@ -6,12 +6,14 @@ import { CardContent, Chip, Grid, + InputAdornment, 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 SearchIcon from "@mui/icons-material/Search"; import { User } from "../../interfaces"; import TristateToggle from "../../components/TristateToggle"; import GridFooterWithButton from "../../components/GridFooterWithButton"; @@ -85,77 +87,80 @@ export const UsersTable = ({ }, [userSearchQuery, usersList.data, isActiveFilter, isTechnicianFilter]); return ( - + - - - + + + - {" "} -  Search Users - - } + sx={{ m: 0, width: '100%', maxWidth: '75rem' }} + placeholder="Search Users..." variant="outlined" size="small" value={userSearchQuery} onChange={(event: any) => setUserSearchQuery(event.target.value)} - sx={{ marginBottom: "10px" }} + InputProps={{ + startAdornment: ( + + + + ), + }} /> - -
-
Choose Filters:
- - setIsActiveFilter(state) - } - /> - - setIsTechnicianFilter(state) - } - /> -
+ + Choose Filters: + + setIsActiveFilter(state) + } + /> + + setIsTechnicianFilter(state) + } + />
- { - setSelectedUser( - usersList.data?.find( - (user: User) => user.id == selectedRow.row.id, - ), - ); - }} - slots={{ footer: GridFooterWithButton }} - slotProps={{ - footer: { - button: ( - - ), - }, - }} - disableColumnFilter - /> + + { + setSelectedUser( + usersList.data?.find( + (user: User) => user.id == selectedRow.row.id, + ), + ); + }} + slots={{ footer: GridFooterWithButton }} + slotProps={{ + footer: { + button: ( + + ), + }, + }} + disableColumnFilter + /> +
-
+
); }; From a4cc4dfaacd3d2aad949317a184bc2fb999f62f4 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 20:28:26 -0500 Subject: [PATCH 140/146] [BackgroundBox] Stop the page contents to be bigger than xl --- frontend/src/components/BackgroundBox.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/BackgroundBox.tsx b/frontend/src/components/BackgroundBox.tsx index bc3f96a1..a2c5bd4f 100644 --- a/frontend/src/components/BackgroundBox.tsx +++ b/frontend/src/components/BackgroundBox.tsx @@ -9,6 +9,8 @@ export const BackgroundBox: React.FC = ({ return ( Date: Tue, 2 Sep 2025 20:32:41 -0500 Subject: [PATCH 141/146] [AppLayout] Rm unneeded theme call --- frontend/src/AppLayout.tsx | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/frontend/src/AppLayout.tsx b/frontend/src/AppLayout.tsx index 9c9c85ae..98281aaf 100644 --- a/frontend/src/AppLayout.tsx +++ b/frontend/src/AppLayout.tsx @@ -2,7 +2,6 @@ import { useAuthUser } from "react-auth-kit"; import { useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Box } from "@mui/material"; -import { Theme } from "@mui/material/styles"; import { SecurityScope } from "./interfaces"; import Topbar from "./components/Topbar"; import Sidenav from "./sidenav"; @@ -75,13 +74,6 @@ export const AppLayout = ({ setDrawerOpen(!drawerOpen)} - sx={(theme: Theme) => ({ - zIndex: theme.zIndex.drawer + 1, - transition: theme.transitions.create(["margin", "width"], { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.leavingScreen, - }), - })} /> ({ + sx={{ flexGrow: 1, flexShrink: 1, minWidth: 0, p: 3, mt: 8, - ...theme.mixins.toolbar, - transition: theme.transitions.create("margin", { - easing: theme.transitions.easing.sharp, - duration: theme.transitions.duration.leavingScreen, - }), - })} + }} > {pageComponent} - + ); }; From 1d3b63a33c4d6055e4bd5bd414fb6b1c64ef4056 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 2 Sep 2025 20:41:00 -0500 Subject: [PATCH 142/146] [index] Update title --- frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index ffa57ae2..20c9a847 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -23,7 +23,7 @@ - Meter Manager DB + Meter Manager From 4a0249506df7cf493e11feeb997c97c32b4c2790 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 3 Sep 2025 08:51:18 -0500 Subject: [PATCH 143/146] [api/Dockerfile] Add postgresql-client to download list --- api/Dockerfile | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 31d54aaf..9c3e543e 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 -# Install system dependencies (trixie) + JDK (default is 21) +# Install system dependencies (trixie) + JDK (default is 21) + PostgreSQL client RUN apt-get update \ && apt-get install -y --no-install-recommends \ libpango-1.0-0 \ @@ -13,6 +13,7 @@ RUN apt-get update \ libgdk-pixbuf-2.0-0 \ libffi-dev \ default-jdk-headless \ + postgresql-client \ && rm -rf /var/lib/apt/lists/* # Make Java headers discoverable by builds @@ -26,7 +27,5 @@ COPY ./requirements.txt . RUN pip install --no-cache-dir --upgrade pip setuptools wheel \ && pip install --no-cache-dir -r requirements.txt -RUN pip install --no-cache-dir --upgrade -r requirements.txt - COPY . /app/api From 7b8f00141c7b944280cb9a40b3be68d447f469db Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 3 Sep 2025 08:59:13 -0500 Subject: [PATCH 144/146] [routes/admin] Patch /backup datetime typeerror --- api/routes/admin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/routes/admin.py b/api/routes/admin.py index 189e6130..432d4946 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -222,7 +222,8 @@ def backup_and_send(): if not DATABASE_URL: raise ValueError("DATABASE_URL environment variable is not set") - timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%d-%H%M%S") + # Use UTC-aware timestamp + 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}") @@ -242,8 +243,8 @@ def backup_and_send(): local_path.unlink(missing_ok=True) - # Delete old backups (> BACKUP_RETENTION_DAYS) - cutoff_date = datetime.datetime.utcnow() - datetime.timedelta(days=BACKUP_RETENTION_DAYS) + # Delete old backups (> BACKUP_RETENTION_DAYS) using UTC-aware cutoff + cutoff_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=BACKUP_RETENTION_DAYS) blobs = client.list_blobs(BUCKET_NAME, prefix=BACKUP_PREFIX) deleted = [] From 0e48dfd8bdf0a5db400bb640049c1efe3bea2887 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 3 Sep 2025 10:11:49 -0500 Subject: [PATCH 145/146] [meter-manager.service] Update service to auto restart & not shutdown containers --- meter-manager.service | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/meter-manager.service b/meter-manager.service index bf6c906e..eaa5bd2a 100644 --- a/meter-manager.service +++ b/meter-manager.service @@ -1,3 +1,5 @@ +# /etc/systemd/system/meter-manager.service + [Unit] Description=Meter Manager Docker Compose Daemon Requires=docker.service @@ -11,10 +13,12 @@ WorkingDirectory=/home/deploy/WaterManagerDB # Default is production Environment="METER_MANAGER_ENV=production" -ExecStart=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml up -d +# Run in foreground so systemd tracks it +ExecStart=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml up ExecStop=/usr/bin/docker compose -f docker-compose.${METER_MANAGER_ENV}.yml down Restart=always +RestartSec=10 [Install] WantedBy=multi-user.target From 8ac5cba52c46232c95ba5662af70b1433d5c2c18 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 3 Sep 2025 10:21:22 -0500 Subject: [PATCH 146/146] [docker-compose.{ENV}] Will store acme.json files instead of asking for new cert everytime --- docker-compose.development.yml | 10 ++++------ docker-compose.production.yml | 12 +++++------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docker-compose.development.yml b/docker-compose.development.yml index f26dffe8..d34cfc80 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -1,5 +1,5 @@ # Docker Compose for development server -# Uses remote database - see configuration in api/.env_production +# Uses remote database - see configuration in api/.env_devserver version: "3.9" services: @@ -76,17 +76,15 @@ services: - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - # for production deployment testing make sure the next line is uncommented. Comment out once you know all app components are working -# - "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - "--certificatesresolvers.myresolver.acme.email=newmexicowaterdata@gmail.com" - - "--certificatesresolvers.myresolver.acme.storage=acme.json" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" -# - "--traefik.http.middlewares.force-secure.redirectscheme.scheme=https" -# - "--traefik.http.middlewares.force-secure.redirectscheme.permanent=true" + ports: - "80:80" - "443:443" - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" + - ./letsencrypt:/letsencrypt diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 4cf17750..c45ec0de 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -7,10 +7,10 @@ services: build: context: ./frontend dockerfile: ./Dockerfile - ports: - - "5173:80" env_file: - ./frontend/.env.production + ports: + - "5173:80" depends_on: - api - traefik @@ -76,18 +76,16 @@ services: - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entrypoints.web.http.redirections.entrypoint.scheme=https" - # for production deployment testing make sure the next line is uncommented. Comment out once you know all app components are working -# - "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" - "--certificatesresolvers.myresolver.acme.email=newmexicowaterdata@gmail.com" - - "--certificatesresolvers.myresolver.acme.storage=acme.json" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" -# - "--traefik.http.middlewares.force-secure.redirectscheme.scheme=https" -# - "--traefik.http.middlewares.force-secure.redirectscheme.permanent=true" + ports: - "80:80" - "443:443" - "8080:8080" volumes: - "/var/run/docker.sock:/var/run/docker.sock:ro" + - ./letsencrypt:/letsencrypt