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 %} + + + + + + + + + + + + {% 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/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 }} + /> + )} + /> + + + + + + + + + + + + + + + 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} /> +