From 0a9482769bc9a6c520760b3f032bb201ef72e770 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 26 Aug 2026 10:31:04 -0500 Subject: [PATCH 1/4] fix(meters): include repair activity in map marker status Add latest Repair activity timestamps to meter map data and use the most recent PM, Repair, or Location Only activity to determine marker color. Also avoid timezone-dependent date parsing when calculating fiscal year colors. --- api/routes/meters.py | 26 ++++++++++++ api/schemas/meter.py | 1 + frontend/src/interfaces/MeterMapDTO.ts | 1 + frontend/src/utils/GetMeterMarkerColor.ts | 40 ++++++++++++++----- .../MeterSelection/MeterSelectionMap.tsx | 8 +++- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/api/routes/meters.py b/api/routes/meters.py index 918c31d5..f90824df 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -323,6 +323,9 @@ def get_meters_locations( location_only_activity_type_id = db.scalars( select(ActivityTypeLU.id).where(ActivityTypeLU.name == "Location Only") ).first() + repair_activity_type_id = db.scalars( + select(ActivityTypeLU.id).where(ActivityTypeLU.name == "Repair") + ).first() if not pm_activity_type_id: raise HTTPException( @@ -334,6 +337,11 @@ def get_meters_locations( status_code=500, detail="Location Only activity type is not configured.", ) + if not repair_activity_type_id: + raise HTTPException( + status_code=500, + detail="Repair activity type is not configured.", + ) # Query latest PMs tied directly to the meter meter_pm_query = text( @@ -351,6 +359,23 @@ def get_meters_locations( ).fetchall() meter_pm_dict = {row.meter_id: row.last_pm_meter_activity for row in meter_pm_rows} + repair_query = text( + """ + SELECT MAX(timestamp_start) AS last_repair_meter_activity, meter_id + FROM "MeterActivities" + WHERE activity_type_id = :repair_activity_type_id + AND meter_id = ANY(:mids) + GROUP BY meter_id + """ + ) + repair_rows = db.execute( + repair_query, + {"mids": meter_ids, "repair_activity_type_id": repair_activity_type_id}, + ).fetchall() + repair_dict = { + row.meter_id: row.last_repair_meter_activity for row in repair_rows + } + location_only_dict = {} if meter_ids: @@ -394,6 +419,7 @@ def get_meters_locations( "trss": row.trss, }, last_pm_meter_activity=meter_pm_dict.get(row.id), + last_repair_meter_activity=repair_dict.get(row.id), last_location_only_meter_activity=location_only_dict.get(row.id), ) ) diff --git a/api/schemas/meter.py b/api/schemas/meter.py index de795569..a731de8d 100644 --- a/api/schemas/meter.py +++ b/api/schemas/meter.py @@ -79,6 +79,7 @@ class LocationDTO(ORMBase): well: WellDTO | None = None location: LocationDTO | None = None last_pm_meter_activity: datetime | None = None + last_repair_meter_activity: datetime | None = None last_location_only_meter_activity: datetime | None = None diff --git a/frontend/src/interfaces/MeterMapDTO.ts b/frontend/src/interfaces/MeterMapDTO.ts index 82f5b7dc..64f257b7 100644 --- a/frontend/src/interfaces/MeterMapDTO.ts +++ b/frontend/src/interfaces/MeterMapDTO.ts @@ -10,5 +10,6 @@ export interface MeterMapDTO { latitude: number; }; last_pm_meter_activity: string | null; + last_repair_meter_activity: string | null; last_location_only_meter_activity: string | null; } diff --git a/frontend/src/utils/GetMeterMarkerColor.ts b/frontend/src/utils/GetMeterMarkerColor.ts index 540d60d1..e30effa1 100644 --- a/frontend/src/utils/GetMeterMarkerColor.ts +++ b/frontend/src/utils/GetMeterMarkerColor.ts @@ -1,14 +1,34 @@ 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() - ]; + const dateParts = last_pm.match(/^(\d{4})-(\d{2})-(\d{2})/); + + if (!dateParts) { + return "black"; } -} + + const year = Number(dateParts[1]); + const month = Number(dateParts[2]); + const fiscalYearStart = month >= 7 ? year : year - 1; + const fiscalYearKey = `${fiscalYearStart}/${fiscalYearStart + 1}`; + + return PM_COLORS[fiscalYearKey] ?? "black"; +}; + +export const getLatestMeterActivityDate = ( + activityDates: Array, +) => { + return activityDates.reduce((latestDate, activityDate) => { + if (!activityDate) { + return latestDate; + } + + if (!latestDate) { + return activityDate; + } + + return new Date(activityDate).getTime() > new Date(latestDate).getTime() + ? activityDate + : latestDate; + }, null); +}; diff --git a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx index 23e1a55b..56430912 100644 --- a/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx +++ b/frontend/src/views/Meters/MeterSelection/MeterSelectionMap.tsx @@ -40,6 +40,7 @@ import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, getMapLayersControlKey, + getLatestMeterActivityDate, getMeterMarkerColor, normalizeMapBaseLayer, normalizeMapOverlayNames, @@ -122,8 +123,11 @@ export default function MeterSelectionMap({ }; const getMarkerColor = (meter: MeterMapDTO) => { - const markerDate = - meter.last_pm_meter_activity ?? meter.last_location_only_meter_activity; + const markerDate = getLatestMeterActivityDate([ + meter.last_pm_meter_activity, + meter.last_repair_meter_activity, + meter.last_location_only_meter_activity, + ]); return markerDate ? getMeterMarkerColor(markerDate) : "black"; }; From 3df86b64485ff6525209f106091be238112c4651 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 26 Aug 2026 11:14:38 -0500 Subject: [PATCH 2/4] chore(constants): rename PM_COLORS -> PM_SEASON_COLORS --- frontend/src/components/maps/MeterMapColorLegend.tsx | 6 +++--- frontend/src/constants.ts | 2 +- frontend/src/utils/GetMeterMarkerColor.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/maps/MeterMapColorLegend.tsx b/frontend/src/components/maps/MeterMapColorLegend.tsx index 253e2a75..e5ada3ef 100644 --- a/frontend/src/components/maps/MeterMapColorLegend.tsx +++ b/frontend/src/components/maps/MeterMapColorLegend.tsx @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { useLeafletContext } from "@react-leaflet/core"; import L from "leaflet"; -import { PM_COLORS } from "@/constants"; +import { PM_SEASON_COLORS } from "@/constants"; export const MeterMapColorLegend = () => { const context = useLeafletContext(); @@ -23,7 +23,7 @@ export const MeterMapColorLegend = () => { title.textContent = "PM Season"; title.style.margin = "0 0 8px 0"; - for (const season in PM_COLORS) { + for (const season in PM_SEASON_COLORS) { const row = L.DomUtil.create("div", "", div); row.style.display = "flex"; row.style.alignItems = "center"; @@ -32,7 +32,7 @@ export const MeterMapColorLegend = () => { const colorBox = L.DomUtil.create("div", "", row); colorBox.style.width = "20px"; colorBox.style.height = "20px"; - colorBox.style.background = PM_COLORS[season]; + colorBox.style.background = PM_SEASON_COLORS[season]; colorBox.style.marginRight = "8px"; colorBox.style.border = "1px solid #ccc"; colorBox.style.borderRadius = "4px"; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 4951edf2..92e5a925 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -121,7 +121,7 @@ export const navConfig: NavItem[] = [ }, ]; -export const PM_COLORS: { [key: string]: string } = { +export const PM_SEASON_COLORS: { [key: string]: string } = { "2020/2021": "brown", "2021/2022": "green", "2022/2023": "purple", diff --git a/frontend/src/utils/GetMeterMarkerColor.ts b/frontend/src/utils/GetMeterMarkerColor.ts index e30effa1..436c9404 100644 --- a/frontend/src/utils/GetMeterMarkerColor.ts +++ b/frontend/src/utils/GetMeterMarkerColor.ts @@ -1,4 +1,4 @@ -import { PM_COLORS } from "../constants"; +import { PM_SEASON_COLORS } from "@/constants"; export const getMeterMarkerColor = (last_pm: string) => { const dateParts = last_pm.match(/^(\d{4})-(\d{2})-(\d{2})/); @@ -12,12 +12,12 @@ export const getMeterMarkerColor = (last_pm: string) => { const fiscalYearStart = month >= 7 ? year : year - 1; const fiscalYearKey = `${fiscalYearStart}/${fiscalYearStart + 1}`; - return PM_COLORS[fiscalYearKey] ?? "black"; + return PM_SEASON_COLORS[fiscalYearKey] ?? "black"; }; export const getLatestMeterActivityDate = ( activityDates: Array, -) => { +): string | null => { return activityDates.reduce((latestDate, activityDate) => { if (!activityDate) { return latestDate; From 41aefc0486209bb391b6594813e86097a8de8fa7 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 26 Aug 2026 14:50:07 -0500 Subject: [PATCH 3/4] feat(stored-meters): init the stored meter report --- api/routes/meters.py | 42 ++ api/services/meters.py | 113 ++++- api/templates/installed_meters_report.html | 4 +- api/templates/sold_meters_report.html | 4 +- api/templates/stored_meters_report.html | 95 ++++ frontend/src/constants.ts | 8 + frontend/src/routeTree.gen.ts | 21 + frontend/src/routes/reports/storedmeters.tsx | 27 ++ .../src/views/Reports/StoredMeters/index.tsx | 455 ++++++++++++++++++ frontend/src/views/Reports/index.tsx | 6 + 10 files changed, 768 insertions(+), 7 deletions(-) create mode 100644 api/templates/stored_meters_report.html create mode 100644 frontend/src/routes/reports/storedmeters.tsx create mode 100644 frontend/src/views/Reports/StoredMeters/index.tsx diff --git a/api/routes/meters.py b/api/routes/meters.py index f90824df..9a33a8aa 100644 --- a/api/routes/meters.py +++ b/api/routes/meters.py @@ -155,6 +155,48 @@ def download_sold_meters_pdf( ) +@authenticated_meter_router.get( + "/meters/stored-report", + dependencies=[Depends(ScopedUser.Read)], + tags=["Meters"], +) +def get_stored_meters_report( + min_size: int | None = Query(None, ge=0), + max_size: int | None = Query(None, ge=0), + db: Session = Depends(get_db), +): + return meter_service.get_stored_meters_report( + db, + min_size, + max_size, + ) + + +@authenticated_meter_router.get( + "/meters/stored-report/pdf", + dependencies=[Depends(ScopedUser.Read)], + tags=["Meters"], +) +def download_stored_meters_pdf( + min_size: int | None = Query(None, ge=0), + max_size: int | None = Query(None, ge=0), + db: Session = Depends(get_db), +): + pdf_io = meter_service.build_stored_meters_pdf( + db, + min_size, + max_size, + ) + + return StreamingResponse( + pdf_io, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=stored_meters_report.pdf" + }, + ) + + @authenticated_meter_router.get( "/meters/installed-report", dependencies=[Depends(ScopedUser.Read)], diff --git a/api/services/meters.py b/api/services/meters.py index a8023529..7d397da4 100644 --- a/api/services/meters.py +++ b/api/services/meters.py @@ -18,6 +18,7 @@ MeterActivities, MeterObservations, Meters, + MeterStatusLU, MeterTypeLU, ) from api.models.location import Locations @@ -123,6 +124,8 @@ def get_meter_history(db: Session, meter_id: int): def _meter_type_label(meter_type: MeterTypeLU) -> str: + size_label = f'{meter_type.size:g}"' if meter_type.size is not None else None + return " ".join( filter( None, @@ -130,7 +133,7 @@ def _meter_type_label(meter_type: MeterTypeLU) -> str: meter_type.brand, meter_type.series, meter_type.model, - f'{meter_type.size:g}"', + size_label, ], ) ) @@ -236,7 +239,86 @@ def get_sold_meters_report( type_totals = sorted( type_totals_by_id.values(), - key=lambda row: (row["size"], row["meter_type"]), + key=lambda row: (row["size"] is None, row["size"] or 0, row["meter_type"]), + ) + + return { + "rows": rows, + "summary": { + "quantity": len(rows), + "total_value": total_value, + }, + "type_totals": type_totals, + } + + +def get_stored_meters_report( + db: Session, + min_size: int | None = None, + max_size: int | None = None, +): + stmt = ( + select(Meters, MeterTypeLU, MeterStatusLU) + .join(MeterTypeLU, MeterTypeLU.id == Meters.meter_type_id) + .join(MeterStatusLU, MeterStatusLU.id == Meters.status_id) + .where(MeterStatusLU.status_name.in_(["Warehouse", "On Hold"])) + .order_by( + MeterTypeLU.size.asc(), + MeterTypeLU.brand.asc(), + Meters.serial_number.asc(), + ) + ) + + if min_size is not None: + stmt = stmt.where(MeterTypeLU.size >= min_size) + if max_size is not None: + stmt = stmt.where(MeterTypeLU.size <= max_size) + + rows = [] + type_totals_by_id = {} + total_value = 0.0 + + for meter, meter_type, status in db.execute(stmt).all(): + price = float(meter.price or 0) + total_value += price + meter_type_label = _meter_type_label(meter_type) + + rows.append( + { + "id": meter.id, + "serial_number": meter.serial_number, + "meter_owner": meter.meter_owner, + "contact_name": meter.contact_name, + "status": status.status_name, + "price": price, + "meter_type_id": meter_type.id, + "meter_type": meter_type_label, + "brand": meter_type.brand, + "series": meter_type.series, + "model": meter_type.model, + "size": meter_type.size, + "description": meter_type.description, + } + ) + + if meter_type.id not in type_totals_by_id: + type_totals_by_id[meter_type.id] = { + "id": meter_type.id, + "meter_type": meter_type_label, + "brand": meter_type.brand, + "series": meter_type.series, + "model": meter_type.model, + "size": meter_type.size, + "description": meter_type.description, + "quantity": 0, + "total_value": 0.0, + } + type_totals_by_id[meter_type.id]["quantity"] += 1 + type_totals_by_id[meter_type.id]["total_value"] += price + + type_totals = sorted( + type_totals_by_id.values(), + key=lambda row: (row["size"] is None, row["size"] or 0, row["meter_type"]), ) return { @@ -327,7 +409,7 @@ def get_installed_meters_report( type_totals = sorted( type_totals_by_id.values(), - key=lambda row: (row["size"], row["meter_type"]), + key=lambda row: (row["size"] is None, row["size"] or 0, row["meter_type"]), ) return { @@ -369,6 +451,31 @@ def build_sold_meters_pdf( return pdf_io +def build_stored_meters_pdf( + db: Session, + min_size: int | None = None, + max_size: int | None = None, +): + report = get_stored_meters_report(db, min_size, max_size) + meter_type_chart = _make_meter_type_bar_chart( + report["type_totals"], + "Meters Stored", + ) + + html_content = templates.get_template("stored_meters_report.html").render( + rows=report["rows"], + summary=report["summary"], + type_totals=report["type_totals"], + meter_type_chart=meter_type_chart, + min_size=min_size, + max_size=max_size, + ) + pdf_io = BytesIO() + HTML(string=html_content).write_pdf(pdf_io) + pdf_io.seek(0) + return pdf_io + + def build_installed_meters_pdf( db: Session, from_date: date, diff --git a/api/templates/installed_meters_report.html b/api/templates/installed_meters_report.html index 8d6ecca3..8b1eda3d 100644 --- a/api/templates/installed_meters_report.html +++ b/api/templates/installed_meters_report.html @@ -56,7 +56,7 @@

Meter Type Totals

{% for row in type_totals %} {{ row.meter_type }} - {{ "%g"|format(row.size) }} + {{ "%g"|format(row.size) if row.size is not none else "" }} {{ row.quantity }} ${{ "%.2f"|format(row.total_value) }} @@ -84,7 +84,7 @@

Installed Meters

{{ row.installed_date.strftime("%Y-%m-%d") }} {{ row.serial_number }} {{ row.meter_type }} - {{ "%g"|format(row.size) }} + {{ "%g"|format(row.size) if row.size is not none else "" }} {{ row.well_ra_number or "" }} {{ row.trss or "" }} {{ row.water_users or "" }} diff --git a/api/templates/sold_meters_report.html b/api/templates/sold_meters_report.html index 8fc7827f..9588d48d 100644 --- a/api/templates/sold_meters_report.html +++ b/api/templates/sold_meters_report.html @@ -56,7 +56,7 @@

Meter Type Totals

{% for row in type_totals %} {{ row.meter_type }} - {{ "%g"|format(row.size) }} + {{ "%g"|format(row.size) if row.size is not none else "" }} {{ row.quantity }} ${{ "%.2f"|format(row.total_value) }} @@ -83,7 +83,7 @@

Sold Meters

{{ row.sold_date.strftime("%Y-%m-%d") }} {{ row.serial_number }} {{ row.meter_type }} - {{ "%g"|format(row.size) }} + {{ "%g"|format(row.size) if row.size is not none else "" }} {{ row.contact_name or "" }} {{ row.meter_owner or "" }} ${{ "%.2f"|format(row.price) }} diff --git a/api/templates/stored_meters_report.html b/api/templates/stored_meters_report.html new file mode 100644 index 00000000..a5e90494 --- /dev/null +++ b/api/templates/stored_meters_report.html @@ -0,0 +1,95 @@ + + + + + Stored Meters Report + + + +

Stored Meters Report

+
+ Current stored meter inventory + {% if min_size is not none or max_size is not none %} + | Meter size: + {{ min_size if min_size is not none else "Any" }} + to + {{ max_size if max_size is not none else "Any" }} + {% endif %} +
+ +
+
Total meters stored{{ summary.quantity }}
+
Total value${{ "%.2f"|format(summary.total_value) }}
+
+ +

Meter Type Totals

+ {% if meter_type_chart %} +
+ +
+ {% endif %} + + + + + + + + + + + + {% for row in type_totals %} + + + + + + + {% endfor %} + +
Meter TypeSizeQuantity StoredTotal Value
{{ row.meter_type }}{{ "%g"|format(row.size) if row.size is not none else "" }}{{ row.quantity }}${{ "%.2f"|format(row.total_value) }}
+ +

Stored Meters

+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
Serial NumberMeter TypeSizeStatusContactOwnerValue
{{ row.serial_number }}{{ row.meter_type }}{{ "%g"|format(row.size) if row.size is not none else "" }}{{ row.status }}{{ row.contact_name or "" }}{{ row.meter_owner or "" }}${{ "%.2f"|format(row.price) }}
+ + diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index 92e5a925..85aecbcc 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -15,6 +15,7 @@ import { Engineering, EngineeringOutlined, SellOutlined, + StorageOutlined, } from "@mui/icons-material"; import { SvgIconProps } from "@mui/material"; import { ComponentType } from "react"; @@ -103,6 +104,13 @@ export const navConfig: NavItem[] = [ role: "Technician", parent: "reports", }, + { + path: "/reports/storedmeters", + label: "Stored Meters", + icon: StorageOutlined, + role: "Technician", + parent: "reports", + }, // Admin { path: "/manage/parts", label: "Manage Parts", icon: Build, role: "Admin" }, diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 667b83e6..2abdc62e 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ActivitiesRouteImport } from './routes/activities' import { Route as IndexRouteImport } from './routes/index' import { Route as ReportsIndexRouteImport } from './routes/reports/index' import { Route as ManageIndexRouteImport } from './routes/manage/index' +import { Route as ReportsStoredmetersRouteImport } from './routes/reports/storedmeters' import { Route as ReportsSoldmetersRouteImport } from './routes/reports/soldmeters' import { Route as ReportsPartsusedRouteImport } from './routes/reports/partsused' import { Route as ReportsMonitoringwellsRouteImport } from './routes/reports/monitoringwells' @@ -86,6 +87,11 @@ const ManageIndexRoute = ManageIndexRouteImport.update({ path: '/manage/', getParentRoute: () => rootRouteImport, } as any) +const ReportsStoredmetersRoute = ReportsStoredmetersRouteImport.update({ + id: '/reports/storedmeters', + path: '/reports/storedmeters', + getParentRoute: () => rootRouteImport, +} as any) const ReportsSoldmetersRoute = ReportsSoldmetersRouteImport.update({ id: '/reports/soldmeters', path: '/reports/soldmeters', @@ -190,6 +196,7 @@ export interface FileRoutesByFullPath { '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute '/reports/soldmeters': typeof ReportsSoldmetersRoute + '/reports/storedmeters': typeof ReportsStoredmetersRoute '/manage/': typeof ManageIndexRoute '/reports/': typeof ReportsIndexRoute '/manage/parts/': typeof ManagePartsIndexRoute @@ -217,6 +224,7 @@ export interface FileRoutesByTo { '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute '/reports/soldmeters': typeof ReportsSoldmetersRoute + '/reports/storedmeters': typeof ReportsStoredmetersRoute '/manage': typeof ManageIndexRoute '/reports': typeof ReportsIndexRoute '/manage/parts': typeof ManagePartsIndexRoute @@ -246,6 +254,7 @@ export interface FileRoutesById { '/reports/monitoringwells': typeof ReportsMonitoringwellsRoute '/reports/partsused': typeof ReportsPartsusedRoute '/reports/soldmeters': typeof ReportsSoldmetersRoute + '/reports/storedmeters': typeof ReportsStoredmetersRoute '/manage/': typeof ManageIndexRoute '/reports/': typeof ReportsIndexRoute '/manage/parts/': typeof ManagePartsIndexRoute @@ -276,6 +285,7 @@ export interface FileRouteTypes { | '/reports/monitoringwells' | '/reports/partsused' | '/reports/soldmeters' + | '/reports/storedmeters' | '/manage/' | '/reports/' | '/manage/parts/' @@ -303,6 +313,7 @@ export interface FileRouteTypes { | '/reports/monitoringwells' | '/reports/partsused' | '/reports/soldmeters' + | '/reports/storedmeters' | '/manage' | '/reports' | '/manage/parts' @@ -331,6 +342,7 @@ export interface FileRouteTypes { | '/reports/monitoringwells' | '/reports/partsused' | '/reports/soldmeters' + | '/reports/storedmeters' | '/manage/' | '/reports/' | '/manage/parts/' @@ -360,6 +372,7 @@ export interface RootRouteChildren { ReportsMonitoringwellsRoute: typeof ReportsMonitoringwellsRoute ReportsPartsusedRoute: typeof ReportsPartsusedRoute ReportsSoldmetersRoute: typeof ReportsSoldmetersRoute + ReportsStoredmetersRoute: typeof ReportsStoredmetersRoute ManageIndexRoute: typeof ManageIndexRoute ReportsIndexRoute: typeof ReportsIndexRoute } @@ -436,6 +449,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ManageIndexRouteImport parentRoute: typeof rootRouteImport } + '/reports/storedmeters': { + id: '/reports/storedmeters' + path: '/reports/storedmeters' + fullPath: '/reports/storedmeters' + preLoaderRoute: typeof ReportsStoredmetersRouteImport + parentRoute: typeof rootRouteImport + } '/reports/soldmeters': { id: '/reports/soldmeters' path: '/reports/soldmeters' @@ -600,6 +620,7 @@ const rootRouteChildren: RootRouteChildren = { ReportsMonitoringwellsRoute: ReportsMonitoringwellsRoute, ReportsPartsusedRoute: ReportsPartsusedRoute, ReportsSoldmetersRoute: ReportsSoldmetersRoute, + ReportsStoredmetersRoute: ReportsStoredmetersRoute, ManageIndexRoute: ManageIndexRoute, ReportsIndexRoute: ReportsIndexRoute, } diff --git a/frontend/src/routes/reports/storedmeters.tsx b/frontend/src/routes/reports/storedmeters.tsx new file mode 100644 index 00000000..101374fb --- /dev/null +++ b/frontend/src/routes/reports/storedmeters.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { StoredMetersReportView } from "@/views/Reports/StoredMeters"; +import { ProtectedRoute } from "@/ProtectedRoute"; +import { + optionalNonNegativeInt, + pageParam, + routeSearchHydrator, +} from "@/utils"; + +const searchSchema = z.object({ + min_size: optionalNonNegativeInt, + max_size: optionalNonNegativeInt, + page: pageParam(0, 0, 1000), + pageSize: pageParam(10, 5, 100), +}); + +export const Route = createFileRoute("/reports/storedmeters")({ + validateSearch: searchSchema, + beforeLoad: ({ search, location }) => + routeSearchHydrator(location.pathname, search, location.searchStr), + component: () => ( + + + + ), +}); diff --git a/frontend/src/views/Reports/StoredMeters/index.tsx b/frontend/src/views/Reports/StoredMeters/index.tsx new file mode 100644 index 00000000..bef7cd5b --- /dev/null +++ b/frontend/src/views/Reports/StoredMeters/index.tsx @@ -0,0 +1,455 @@ +import { useEffect, useMemo } from "react"; +import { useAuthHeader } from "react-auth-kit"; +import { PictureAsPdf, Storage } from "@mui/icons-material"; +import { + Box, + Button, + Card, + CardContent, + Grid, + Skeleton, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import { BarChart } from "@mui/x-charts"; +import { useNavigate } from "@tanstack/react-router"; +import { Controller, useForm } from "react-hook-form"; +import { useMutation, useQuery } from "react-query"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; + +import { API_URL } from "@/config"; +import { + BackgroundBox, + CustomCardHeader, + ReportBreadcrumbTitle, +} from "@/components"; +import { Route } from "@/routes/reports/storedmeters"; + +type StoredMeterRow = { + id: number; + serial_number: string; + meter_owner: string | null; + contact_name: string | null; + status: string; + price: number; + meter_type_id: number; + meter_type: string; + brand: string; + series: string | null; + model: string; + size: number | null; + description: string; +}; + +type MeterTypeTotal = { + id: number; + meter_type: string; + size: number | null; + quantity: number; + total_value: number; +}; + +type StoredMetersReport = { + rows: StoredMeterRow[]; + summary: { + quantity: number; + total_value: number; + }; + type_totals: MeterTypeTotal[]; +}; + +type FormValues = { + min_size?: number | null; + max_size?: number | null; +}; + +const schema = yup.object().shape({ + min_size: yup.number().nullable().min(0).integer(), + max_size: yup + .number() + .nullable() + .min(0) + .integer() + .test("is-at-least-min", "Max size must be at least min size", function (value) { + const { min_size } = this.parent; + return value == null || min_size == null || value >= min_size; + }), +}); + +const formatCurrency = (value: number | null | undefined) => + `$${(value ?? 0).toFixed(2)}`; + +const formatSize = (value: number | null | undefined) => + value == null ? "" : value.toString(); + +export const StoredMetersReportView = () => { + const navigate = useNavigate(); + const search = Route.useSearch(); + const authHeader = useAuthHeader(); + + const defaultValues = useMemo( + () => ({ + min_size: search.min_size ?? null, + max_size: search.max_size ?? null, + }), + [search.min_size, search.max_size], + ); + + const { control, reset, watch } = useForm({ + resolver: yupResolver(schema), + defaultValues, + }); + + useEffect(() => { + reset(defaultValues); + }, [defaultValues, reset]); + + const minSize = watch("min_size"); + const maxSize = watch("max_size"); + + const setSearch = (updater: (prev: typeof search) => any) => { + navigate({ + to: "/reports/storedmeters", + search: (prev) => updater(prev as any), + replace: true, + }); + }; + + useEffect(() => { + const nextMinSize = minSize ?? undefined; + const nextMaxSize = maxSize ?? undefined; + + setSearch((prev) => { + if (prev.min_size === nextMinSize && prev.max_size === nextMaxSize) { + return prev; + } + + return { + ...prev, + min_size: nextMinSize, + max_size: nextMaxSize, + page: 0, + }; + }); + }, [minSize, maxSize]); + + const buildParams = () => { + const params = new URLSearchParams(); + + if (search.min_size != null) { + params.set("min_size", search.min_size.toString()); + } + if (search.max_size != null) { + params.set("max_size", search.max_size.toString()); + } + + return params; + }; + + const reportQuery = useQuery({ + queryKey: ["Meters", "report", "storedmeters", search], + queryFn: async () => { + const response = await fetch( + `${API_URL}/meters/stored-report?${buildParams().toString()}`, + { + headers: { Authorization: authHeader() }, + }, + ); + + if (!response.ok) { + throw new Error("Failed to fetch stored meters report"); + } + + return response.json(); + }, + }); + + const downloadPDFMutation = useMutation({ + mutationFn: async () => { + const response = await fetch( + `${API_URL}/meters/stored-report/pdf?${buildParams().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 = "stored_meters_report.pdf"; + a.click(); + window.URL.revokeObjectURL(url); + }, + }); + + const rows = reportQuery.data?.rows ?? []; + const typeTotals = reportQuery.data?.type_totals ?? []; + const summary = reportQuery.data?.summary ?? { quantity: 0, total_value: 0 }; + + const columns: GridColDef[] = [ + { field: "serial_number", headerName: "Serial Number", flex: 1, minWidth: 140 }, + { field: "meter_type", headerName: "Meter Type", flex: 1.6, minWidth: 220 }, + { + field: "size", + headerName: "Size", + flex: 0.6, + minWidth: 80, + type: "number", + valueFormatter: (value: number | null) => formatSize(value), + }, + { field: "status", headerName: "Status", flex: 0.8, minWidth: 120 }, + { + field: "price", + headerName: "Value", + flex: 0.8, + minWidth: 110, + type: "number", + valueFormatter: (value: number) => formatCurrency(value), + }, + { field: "contact_name", headerName: "Contact", flex: 1, minWidth: 140 }, + { field: "meter_owner", headerName: "Owner", flex: 1, minWidth: 120 }, + ]; + + const typeTotalColumns: GridColDef[] = [ + { field: "meter_type", headerName: "Meter Type", flex: 1.5, minWidth: 180 }, + { + field: "size", + headerName: "Size", + flex: 0.5, + minWidth: 80, + type: "number", + valueFormatter: (value: number | null) => formatSize(value), + }, + { + field: "quantity", + headerName: "Stored", + flex: 0.5, + minWidth: 80, + type: "number", + }, + { + field: "total_value", + headerName: "Total Value", + flex: 0.8, + minWidth: 120, + type: "number", + valueFormatter: (value: number) => formatCurrency(value), + }, + ]; + + return ( + + + } + icon={Storage} + /> + + + + ( + + field.onChange( + event.target.value === "" + ? null + : Number(event.target.value), + ) + } + fullWidth + size="small" + type="number" + label="Min Size" + error={!!fieldState.error} + helperText={fieldState.error?.message} + inputProps={{ min: 0, step: 1 }} + /> + )} + /> + + + ( + + field.onChange( + event.target.value === "" + ? null + : Number(event.target.value), + ) + } + fullWidth + size="small" + type="number" + label="Max Size" + error={!!fieldState.error} + helperText={fieldState.error?.message} + inputProps={{ min: 0, step: 1 }} + /> + )} + /> + + + + + + + + + + + + + + + Total Meters Stored + + + {summary.quantity} + + + + + + + Total Value + + + {formatCurrency(summary.total_value)} + + + + + + + Meter Types Stored + + + {typeTotals.length} + + + + + + + + Meter Type Totals + + + + {reportQuery.isLoading ? ( + + ) : typeTotals.length ? ( + row.meter_type), + }, + ]} + series={[ + { + data: typeTotals.map((row) => row.quantity), + label: "Meters Stored", + }, + ]} + /> + ) : ( + + No stored meters found. + + )} + + + + + + + + + + setSearch((prev) => ({ + ...prev, + pageSize: model.pageSize, + page: model.pageSize !== prev.pageSize ? 0 : model.page, + })) + } + /> + + + + + + + + + ); +}; diff --git a/frontend/src/views/Reports/index.tsx b/frontend/src/views/Reports/index.tsx index 1d0dbf8c..692c05a4 100644 --- a/frontend/src/views/Reports/index.tsx +++ b/frontend/src/views/Reports/index.tsx @@ -6,6 +6,7 @@ import { MonitorHeartOutlined, ScienceOutlined, SellOutlined, + StorageOutlined, } from "@mui/icons-material"; import { Box, Card, CardContent } from "@mui/material"; import { BackgroundBox, CustomCardHeader, NavLink } from "@/components"; @@ -47,6 +48,11 @@ export const ReportsView = () => { label="Sold Meters" icon={SellOutlined} /> + From 646dafd207115be98d97afe5b9d6e433d042f281 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Wed, 26 Aug 2026 14:55:16 -0500 Subject: [PATCH 4/4] refactor(Reports): update the installed, sold, and stored meters reports --- .../views/Reports/InstalledMeters/index.tsx | 85 ++++++++++++++++--- .../src/views/Reports/SoldMeters/index.tsx | 71 +++++++++++++--- .../src/views/Reports/StoredMeters/index.tsx | 55 ++++++++++-- 3 files changed, 178 insertions(+), 33 deletions(-) diff --git a/frontend/src/views/Reports/InstalledMeters/index.tsx b/frontend/src/views/Reports/InstalledMeters/index.tsx index 5d7004b5..75b46b38 100644 --- a/frontend/src/views/Reports/InstalledMeters/index.tsx +++ b/frontend/src/views/Reports/InstalledMeters/index.tsx @@ -90,10 +90,14 @@ const schema = yup.object().shape({ .nullable() .min(0) .integer() - .test("is-at-least-min", "Max size must be at least min size", function (value) { - const { min_size } = this.parent; - return value == null || min_size == null || value >= min_size; - }), + .test( + "is-at-least-min", + "Max size must be at least min size", + function (value) { + const { min_size } = this.parent; + return value == null || min_size == null || value >= min_size; + }, + ), }); const formatCurrency = (value: number | null | undefined) => @@ -238,25 +242,56 @@ export const InstalledMetersReportView = () => { minWidth: 130, valueFormatter: (value: string) => dayjs(value).format("YYYY-MM-DD"), }, - { field: "serial_number", headerName: "Serial Number", flex: 1, minWidth: 140 }, + { + field: "serial_number", + headerName: "Serial Number", + flex: 1, + minWidth: 140, + }, { field: "meter_type", headerName: "Meter Type", flex: 1.6, minWidth: 220 }, - { field: "size", headerName: "Size", flex: 0.6, minWidth: 80, type: "number" }, - { field: "well_ra_number", headerName: "RA Number", flex: 0.8, minWidth: 120 }, + { + field: "size", + headerName: "Size", + flex: 0.6, + minWidth: 80, + type: "number", + align: "left", + headerAlign: "left", + }, + { + field: "well_ra_number", + headerName: "RA Number", + flex: 0.8, + minWidth: 120, + }, { field: "trss", headerName: "TRSS", flex: 0.8, minWidth: 120 }, - { field: "water_users", headerName: "Water Users", flex: 1.2, minWidth: 160 }, + { + field: "water_users", + headerName: "Water Users", + flex: 1.2, + minWidth: 160, + }, { field: "price", headerName: "Value", flex: 0.8, minWidth: 110, type: "number", + align: "left", + headerAlign: "left", valueFormatter: (value: number) => formatCurrency(value), }, ]; const typeTotalColumns: GridColDef[] = [ { field: "meter_type", headerName: "Meter Type", flex: 1.5, minWidth: 180 }, - { field: "size", headerName: "Size", flex: 0.5, minWidth: 80, type: "number" }, + { + field: "size", + headerName: "Size", + flex: 0.5, + minWidth: 80, + type: "number", + }, { field: "quantity", headerName: "Installed", @@ -363,7 +398,10 @@ export const InstalledMetersReportView = () => { item xs={12} md={2} - sx={{ display: "flex", justifyContent: { xs: "center", md: "flex-end" } }} + sx={{ + display: "flex", + justifyContent: { xs: "center", md: "flex-end" }, + }} > @@ -384,7 +422,14 @@ export const InstalledMetersReportView = () => { - + Total Meters Installed @@ -394,7 +439,14 @@ export const InstalledMetersReportView = () => { - + Total Value @@ -404,7 +456,14 @@ export const InstalledMetersReportView = () => { - + Meter Types Installed diff --git a/frontend/src/views/Reports/SoldMeters/index.tsx b/frontend/src/views/Reports/SoldMeters/index.tsx index a582c19d..ee5c0452 100644 --- a/frontend/src/views/Reports/SoldMeters/index.tsx +++ b/frontend/src/views/Reports/SoldMeters/index.tsx @@ -87,10 +87,14 @@ const schema = yup.object().shape({ .nullable() .min(0) .integer() - .test("is-at-least-min", "Max size must be at least min size", function (value) { - const { min_size } = this.parent; - return value == null || min_size == null || value >= min_size; - }), + .test( + "is-at-least-min", + "Max size must be at least min size", + function (value) { + const { min_size } = this.parent; + return value == null || min_size == null || value >= min_size; + }, + ), }); const formatCurrency = (value: number | null | undefined) => @@ -243,9 +247,22 @@ export const SoldMetersReportView = () => { minWidth: 120, valueFormatter: (value: string) => dayjs(value).format("YYYY-MM-DD"), }, - { field: "serial_number", headerName: "Serial Number", flex: 1, minWidth: 140 }, + { + field: "serial_number", + headerName: "Serial Number", + flex: 1, + minWidth: 140, + }, { field: "meter_type", headerName: "Meter Type", flex: 1.6, minWidth: 220 }, - { field: "size", headerName: "Size", flex: 0.6, minWidth: 80, type: "number" }, + { + field: "size", + headerName: "Size", + flex: 0.6, + minWidth: 80, + type: "number", + align: "left", + headerAlign: "left", + }, { field: "price", headerName: "Value", @@ -253,6 +270,8 @@ export const SoldMetersReportView = () => { minWidth: 110, type: "number", valueFormatter: (value: number) => formatCurrency(value), + align: "left", + headerAlign: "left", }, { field: "contact_name", headerName: "Contact", flex: 1, minWidth: 140 }, { field: "meter_owner", headerName: "Owner", flex: 1, minWidth: 120 }, @@ -260,7 +279,13 @@ export const SoldMetersReportView = () => { const typeTotalColumns: GridColDef[] = [ { field: "meter_type", headerName: "Meter Type", flex: 1.5, minWidth: 180 }, - { field: "size", headerName: "Size", flex: 0.5, minWidth: 80, type: "number" }, + { + field: "size", + headerName: "Size", + flex: 0.5, + minWidth: 80, + type: "number", + }, { field: "quantity", headerName: "Sold", @@ -367,7 +392,10 @@ export const SoldMetersReportView = () => { item xs={12} md={2} - sx={{ display: "flex", justifyContent: { xs: "center", md: "flex-end" } }} + sx={{ + display: "flex", + justifyContent: { xs: "center", md: "flex-end" }, + }} > @@ -388,7 +416,14 @@ export const SoldMetersReportView = () => { - + Total Meters Sold @@ -398,7 +433,14 @@ export const SoldMetersReportView = () => { - + Total Value @@ -408,7 +450,14 @@ export const SoldMetersReportView = () => { - + Meter Types Sold diff --git a/frontend/src/views/Reports/StoredMeters/index.tsx b/frontend/src/views/Reports/StoredMeters/index.tsx index bef7cd5b..a27756d8 100644 --- a/frontend/src/views/Reports/StoredMeters/index.tsx +++ b/frontend/src/views/Reports/StoredMeters/index.tsx @@ -73,10 +73,14 @@ const schema = yup.object().shape({ .nullable() .min(0) .integer() - .test("is-at-least-min", "Max size must be at least min size", function (value) { - const { min_size } = this.parent; - return value == null || min_size == null || value >= min_size; - }), + .test( + "is-at-least-min", + "Max size must be at least min size", + function (value) { + const { min_size } = this.parent; + return value == null || min_size == null || value >= min_size; + }, + ), }); const formatCurrency = (value: number | null | undefined) => @@ -195,7 +199,12 @@ export const StoredMetersReportView = () => { const summary = reportQuery.data?.summary ?? { quantity: 0, total_value: 0 }; const columns: GridColDef[] = [ - { field: "serial_number", headerName: "Serial Number", flex: 1, minWidth: 140 }, + { + field: "serial_number", + headerName: "Serial Number", + flex: 1, + minWidth: 140, + }, { field: "meter_type", headerName: "Meter Type", flex: 1.6, minWidth: 220 }, { field: "size", @@ -204,6 +213,8 @@ export const StoredMetersReportView = () => { minWidth: 80, type: "number", valueFormatter: (value: number | null) => formatSize(value), + align: "left", + headerAlign: "left", }, { field: "status", headerName: "Status", flex: 0.8, minWidth: 120 }, { @@ -213,6 +224,8 @@ export const StoredMetersReportView = () => { minWidth: 110, type: "number", valueFormatter: (value: number) => formatCurrency(value), + align: "left", + headerAlign: "left", }, { field: "contact_name", headerName: "Contact", flex: 1, minWidth: 140 }, { field: "meter_owner", headerName: "Owner", flex: 1, minWidth: 120 }, @@ -310,7 +323,10 @@ export const StoredMetersReportView = () => { item xs={12} md={6} - sx={{ display: "flex", justifyContent: { xs: "center", md: "flex-end" } }} + sx={{ + display: "flex", + justifyContent: { xs: "center", md: "flex-end" }, + }} > @@ -331,7 +347,14 @@ export const StoredMetersReportView = () => { - + Total Meters Stored @@ -341,7 +364,14 @@ export const StoredMetersReportView = () => { - + Total Value @@ -351,7 +381,14 @@ export const StoredMetersReportView = () => { - + Meter Types Stored