diff --git a/api/routes/meters.py b/api/routes/meters.py
index 918c31d5..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)],
@@ -323,6 +365,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 +379,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 +401,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 +461,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/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 %}
+
+
+
+
+ Meter Type
+ Size
+ Quantity Stored
+ Total Value
+
+
+
+ {% for row in type_totals %}
+
+ {{ row.meter_type }}
+ {{ "%g"|format(row.size) if row.size is not none else "" }}
+ {{ row.quantity }}
+ ${{ "%.2f"|format(row.total_value) }}
+
+ {% endfor %}
+
+
+
+ Stored Meters
+
+
+
+ Serial Number
+ Meter Type
+ Size
+ Status
+ Contact
+ Owner
+ Value
+
+
+
+ {% for row in rows %}
+
+ {{ 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) }}
+
+ {% endfor %}
+
+
+
+
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..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" },
@@ -121,7 +129,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/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/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/utils/GetMeterMarkerColor.ts b/frontend/src/utils/GetMeterMarkerColor.ts
index 540d60d1..436c9404 100644
--- a/frontend/src/utils/GetMeterMarkerColor.ts
+++ b/frontend/src/utils/GetMeterMarkerColor.ts
@@ -1,14 +1,34 @@
-import { PM_COLORS } from "../constants";
+import { PM_SEASON_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_SEASON_COLORS[fiscalYearKey] ?? "black";
+};
+
+export const getLatestMeterActivityDate = (
+ activityDates: Array,
+): string | null => {
+ 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";
};
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
new file mode 100644
index 00000000..a27756d8
--- /dev/null
+++ b/frontend/src/views/Reports/StoredMeters/index.tsx
@@ -0,0 +1,492 @@
+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),
+ align: "left",
+ headerAlign: "left",
+ },
+ { 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),
+ align: "left",
+ headerAlign: "left",
+ },
+ { 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 }}
+ />
+ )}
+ />
+
+
+
+
+ }
+ aria-label="export report as pdf"
+ onClick={() => downloadPDFMutation.mutate()}
+ disabled={downloadPDFMutation.isLoading}
+ sx={{ whiteSpace: "nowrap" }}
+ >
+ PDF
+
+
+
+
+
+
+
+
+
+
+ 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,
+ }))
+ }
+ />
+
+
+
+ {
+ reset({
+ min_size: null,
+ max_size: null,
+ });
+ setSearch((prev) => ({
+ ...prev,
+ min_size: undefined,
+ max_size: undefined,
+ page: 0,
+ pageSize: 10,
+ }));
+ }}
+ >
+ Reset
+
+
+
+
+
+ );
+};
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}
/>
+