From ee022b83c4e0e310ae8400bdaf617d1640ac8ca7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 16 Dec 2025 08:24:43 +0000 Subject: [PATCH] Added measures table --- .../scenario/[scenarioId]/measures/route.ts | 149 ++++++++ .../scenario/[scenarioId]/metrics/route.ts | 63 ++-- .../reporting/ScenarioMeasuresModal.tsx | 320 ++++++++++++++++++ 3 files changed, 509 insertions(+), 23 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts new file mode 100644 index 0000000..63c1ba3 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/measures/route.ts @@ -0,0 +1,149 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +type MeasureAggregateRow = { + measure_type: string | null; + type: string | null; + includes_battery: boolean | null; + homes_count: number; + total_cost: number | null; + average_cost: number | null; +}; + +export async function GET( + request: NextRequest, + props: { params: Promise<{ portfolioId: string; scenarioId: string }> } +) { + const { portfolioId, scenarioId } = await props.params; + + const pid = BigInt(portfolioId); + const sid = BigInt(scenarioId); + + // TEMP: Remove batteries as underspecified + // const result = await db.execute(sql` + // WITH latest_plans AS ( + // SELECT DISTINCT ON (property_id) + // * + // FROM plan + // WHERE portfolio_id = ${pid} + // AND scenario_id = ${sid} + // ORDER BY property_id, created_at DESC + // ), + + // recommendation_flags AS ( + // SELECT + // r.id AS recommendation_id, + // r.measure_type AS measure_type, + // r.property_id AS property_id, + // r.estimated_cost AS estimated_cost, + // BOOL_OR(m.includes_battery) AS includes_battery + + // FROM latest_plans lp + // JOIN plan_recommendations pr + // ON pr.plan_id = lp.id + // JOIN recommendation r + // ON r.id = pr.recommendation_id + + // LEFT JOIN recommendation_materials rm + // ON rm.recommendation_id = r.id + // LEFT JOIN material m + // ON m.id = rm.material_id + // AND m.is_active = true + + // WHERE r.default = true + // AND r.already_installed = false + + // GROUP BY + // r.id, + // r.measure_type, + // r.property_id, + // r.estimated_cost + // ) + + // SELECT + // measure_type, + // COALESCE(includes_battery, false) AS includes_battery, + + // COUNT(DISTINCT property_id)::int AS homes_count, + // SUM(estimated_cost)::float AS total_cost, + // AVG(estimated_cost)::float AS average_cost + + // FROM recommendation_flags + // GROUP BY + // measure_type, + // includes_battery + // ORDER BY total_cost DESC; + // `); + + const result = await db.execute(sql` + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + ORDER BY property_id, created_at DESC + ), + + recommendation_flags AS ( + SELECT + r.id AS recommendation_id, + r.measure_type AS measure_type, + r.property_id AS property_id, + r.estimated_cost AS estimated_cost, + r.type AS type + + FROM latest_plans lp + JOIN plan_recommendations pr + ON pr.plan_id = lp.id + JOIN recommendation r + ON r.id = pr.recommendation_id + + LEFT JOIN recommendation_materials rm + ON rm.recommendation_id = r.id + LEFT JOIN material m + ON m.id = rm.material_id + AND m.is_active = true + + WHERE r.default = true + AND r.already_installed = false + + GROUP BY + r.id, + r.measure_type, + r.type, + r.property_id, + r.estimated_cost + ) + + SELECT + measure_type, + type, + + COUNT(DISTINCT property_id)::int AS homes_count, + SUM(estimated_cost)::float AS total_cost, + AVG(estimated_cost)::float AS average_cost + + FROM recommendation_flags + GROUP BY + measure_type, + type + ORDER BY total_cost DESC; + `); + + const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({ + measureType: row.measure_type ?? "unknown", + type: row.type ?? "unknown", + homesCount: row.homes_count, + totalCost: Number(row.total_cost ?? 0), + averageCost: Number(row.average_cost ?? 0), + // includesBattery: row.includes_battery ?? false, + })); + + return NextResponse.json({ + portfolioId: Number(portfolioId), + scenarioId: Number(scenarioId), + measures, + }); +} diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index 03a7c2c..f81c7f3 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -34,22 +34,28 @@ export async function GET( // ---------------------------------------------------------- // const baselineResult = await db.execute(sql` - SELECT - COUNT(*)::int AS n_units, + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + ORDER BY property_id, created_at DESC + ) - AVG(post_sap_points)::float AS avg_sap, - AVG(post_co2_emissions)::float AS avg_carbon, - AVG(post_energy_bill)::float AS avg_bills, + SELECT + COUNT(*)::int AS n_units, - SUM(post_co2_emissions)::float AS total_carbon, - SUM(post_energy_bill)::float AS total_bills, + AVG(post_sap_points)::float AS avg_sap, + AVG(post_co2_emissions)::float AS avg_carbon, + AVG(post_energy_bill)::float AS avg_bills, - ARRAY_AGG(post_sap_points) AS sap_points_array + SUM(post_co2_emissions)::float AS total_carbon, + SUM(post_energy_bill)::float AS total_bills, - FROM plan p - WHERE p.portfolio_id = ${pid} - AND p.scenario_id = ${sid}; - `); + ARRAY_AGG(post_sap_points) AS sap_points_array + FROM latest_plans; +`); const baseline = baselineResult.rows[0] as BaselineAggregates | undefined; @@ -76,18 +82,29 @@ export async function GET( // ---------------------------------------------------------- // const upgradedResult = await db.execute(sql` - SELECT - COUNT(*)::int AS n_units_upgraded, - SUM(cost_of_works)::float AS total_cost, - SUM(contingency_cost)::float AS contingency, - SUM(COALESCE(fp.project_funding,0) + COALESCE(fp.total_uplift,0))::float AS total_funding + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + ORDER BY property_id, created_at DESC + ) - FROM plan p - LEFT JOIN funding_package fp ON fp.plan_id = p.id - WHERE p.portfolio_id = ${pid} - AND p.scenario_id = ${sid} - AND p.cost_of_works > 0.01; - `); + SELECT + COUNT(*)::int AS n_units_upgraded, + SUM(cost_of_works)::float AS total_cost, + SUM(contingency_cost)::float AS contingency, + SUM( + COALESCE(fp.project_funding, 0) + + COALESCE(fp.total_uplift, 0) + )::float AS total_funding + + FROM latest_plans lp + LEFT JOIN funding_package fp + ON fp.plan_id = lp.id + WHERE lp.cost_of_works > 0.01; +`); const upgraded = upgradedResult.rows[0] as UpgradedAggregates; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx new file mode 100644 index 0000000..cb34694 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioMeasuresModal.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { + Dialog, + DialogBackdrop, + DialogPanel, + DialogTitle, + Transition, +} from "@headlessui/react"; +import { Fragment, useMemo } from "react"; + +/* ------------------------------------------------ + Types +------------------------------------------------ */ + +interface ScenarioMeasuresModalProps { + isOpen: boolean; + onClose: () => void; + isLoading: boolean; + data: any | null; + error: unknown; +} + +type ScenarioMeasure = { + measureType: string; + homesCount: number; + totalCost: number; + averageCost: number; +}; + +export type MeasureCategory = + | "Wall insulation" + | "Roof insulation" + | "Floor insulation" + | "Ventilation & airtightness" + | "Windows & glazing" + | "Solar" + | "Heating" + | "Heating controls" + | "Lighting" + | "Scaffolding & enabling works" + | "Other"; + +/* ------------------------------------------------ + Category mapping +------------------------------------------------ */ + +export const MEASURE_CATEGORY_MAP: Record = { + internal_wall_insulation: "Wall insulation", + external_wall_insulation: "Wall insulation", + cavity_wall_insulation: "Wall insulation", + cavity_wall_extraction: "Wall insulation", + + loft_insulation: "Roof insulation", + flat_roof_insulation: "Roof insulation", + room_roof_insulation: "Roof insulation", + + suspended_floor_insulation: "Floor insulation", + solid_floor_insulation: "Floor insulation", + exposed_floor_insulation: "Floor insulation", + + mechanical_ventilation: "Ventilation & airtightness", + trickle_vent: "Ventilation & airtightness", + door_undercut: "Ventilation & airtightness", + sealing_fireplace: "Ventilation & airtightness", + + windows_glazing: "Windows & glazing", + double_glazing: "Windows & glazing", + secondary_glazing: "Windows & glazing", + + solar_pv: "Solar", + solar_battery: "Solar", + + air_source_heat_pump: "Heating", + boiler_upgrade: "Heating", + high_heat_retention_storage_heaters: "Heating", + + roomstat_programmer_trvs: "Heating controls", + time_temperature_zone_control: "Heating controls", + + low_energy_lighting_installation: "Lighting", + + scaffolding: "Scaffolding & enabling works", +}; + +function getMeasureCategory(measureType: string): MeasureCategory { + return MEASURE_CATEGORY_MAP[measureType] ?? "Other"; +} + +/* ------------------------------------------------ + Helpers +------------------------------------------------ */ + +function toTitleCase(value: string) { + return value + .replaceAll("_", " ") + .toLowerCase() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +type GroupedMeasures = { + category: MeasureCategory; + rows: ScenarioMeasure[]; + homesTotal: number; + costTotal: number; +}; + +function groupMeasuresByCategory( + measures: ScenarioMeasure[] +): GroupedMeasures[] { + const map = new Map(); + + for (const m of measures) { + const category = getMeasureCategory(m.measureType); + if (!map.has(category)) map.set(category, []); + map.get(category)!.push(m); + } + + return Array.from(map.entries()).map(([category, rows]) => ({ + category, + rows, + homesTotal: rows.reduce((s, r) => s + r.homesCount, 0), + costTotal: rows.reduce((s, r) => s + r.totalCost, 0), + })); +} + +/* ------------------------------------------------ + CSV download +------------------------------------------------ */ + +function downloadMeasuresCsv(groups: GroupedMeasures[]) { + const lines: string[] = []; + lines.push("Category,Measure,Homes,Total cost (£),Average cost (£)"); + + for (const group of groups) { + for (const m of group.rows) { + lines.push( + [ + group.category, + toTitleCase(m.measureType), + m.homesCount, + m.totalCost.toFixed(0), + m.averageCost.toFixed(2), + ].join(",") + ); + } + + // Subtotal + lines.push( + [ + group.category, + "Subtotal", + group.homesTotal, + group.costTotal.toFixed(0), + "", + ].join(",") + ); + } + + const blob = new Blob([lines.join("\n")], { + type: "text/csv;charset=utf-8;", + }); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "scenario-measures.csv"; + link.click(); +} + +/* ------------------------------------------------ + Component +------------------------------------------------ */ + +export function ScenarioMeasuresModal({ + isOpen, + onClose, + isLoading, + data, + error, +}: ScenarioMeasuresModalProps) { + const measures: ScenarioMeasure[] = data?.measures ?? []; + + const grouped = useMemo(() => groupMeasuresByCategory(measures), [measures]); + + return ( + + +
+ {isLoading && ( + + )} + + + + + + Scenario measures + + + {/* Actions */} +
+

+ {measures.length} measures +

+ + +
+ + {/* Content */} +
+ {isLoading && ( +
Loading measures…
+ )} + + {Boolean(error) && ( +
+ Failed to load measures. +
+ )} + + {!isLoading && grouped.length > 0 && ( +
+ + + + + + + + + + + + {grouped.map((group) => ( + + {/* Category header */} + + + + + {/* Rows */} + {group.rows.map((row) => ( + + + + + + + ))} + + {/* Subtotal */} + + + + + + + ))} + +
+ Measure + + Homes + + Total cost + + Avg. cost +
+ {group.category} +
+ {toTitleCase(row.measureType)} + + {row.homesCount.toLocaleString()} + + £{row.totalCost.toLocaleString()} + + £ + {row.averageCost.toLocaleString(undefined, { + maximumFractionDigits: 0, + })} +
+ Subtotal + + {group.homesTotal.toLocaleString()} + + £{group.costTotal.toLocaleString()} + +
+
+ )} +
+ +
+ +
+
+
+
+
+ ); +}