Added measures table

This commit is contained in:
Khalim Conn-Kowlessar 2025-12-16 08:24:43 +00:00
parent 9c078464fb
commit ee022b83c4
3 changed files with 509 additions and 23 deletions

View file

@ -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,
});
}

View file

@ -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;

View file

@ -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<string, MeasureCategory> = {
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<MeasureCategory, ScenarioMeasure[]>();
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 (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
className="fixed inset-0 z-50 overflow-y-auto"
onClose={onClose}
>
<div className="min-h-screen px-4 text-center">
{isLoading && (
<DialogBackdrop className="fixed inset-0 bg-black/30" />
)}
<span className="inline-block h-screen align-middle" />
<DialogPanel className="inline-block w-full max-w-5xl p-6 my-8 text-left align-middle bg-white shadow-xl rounded-2xl">
<DialogTitle className="text-lg font-semibold text-gray-900">
Scenario measures
</DialogTitle>
{/* Actions */}
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
{measures.length} measures
</p>
<button
onClick={() => downloadMeasuresCsv(grouped)}
disabled={!measures.length}
className="rounded-md border px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50"
>
Download CSV
</button>
</div>
{/* Content */}
<div className="mt-4">
{isLoading && (
<div className="text-sm text-gray-500">Loading measures</div>
)}
{Boolean(error) && (
<div className="text-sm text-red-600">
Failed to load measures.
</div>
)}
{!isLoading && grouped.length > 0 && (
<div className="overflow-x-auto rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-700">
Measure
</th>
<th className="px-4 py-2 text-right font-medium text-gray-700">
Homes
</th>
<th className="px-4 py-2 text-right font-medium text-gray-700">
Total cost
</th>
<th className="px-4 py-2 text-right font-medium text-gray-700">
Avg. cost
</th>
</tr>
</thead>
<tbody>
{grouped.map((group) => (
<Fragment key={group.category}>
{/* Category header */}
<tr className="bg-gray-100 border-t border-brandmidblue border-b">
<td
colSpan={4}
className="px-4 py-3 text-sm font-semibold text-gray-700 tracking-wide"
>
{group.category}
</td>
</tr>
{/* Rows */}
{group.rows.map((row) => (
<tr key={row.measureType} className="border-t">
<td className="px-4 py-2">
{toTitleCase(row.measureType)}
</td>
<td className="px-4 py-2 text-right">
{row.homesCount.toLocaleString()}
</td>
<td className="px-4 py-2 text-right">
£{row.totalCost.toLocaleString()}
</td>
<td className="px-4 py-2 text-right text-gray-600">
£
{row.averageCost.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}
</td>
</tr>
))}
{/* Subtotal */}
<tr className="border-t bg-gray-50">
<td className="px-4 py-2 font-medium text-gray-700">
Subtotal
</td>
<td className="px-4 py-2 text-right font-medium">
{group.homesTotal.toLocaleString()}
</td>
<td className="px-4 py-2 text-right font-medium">
£{group.costTotal.toLocaleString()}
</td>
<td />
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={onClose}
className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Close
</button>
</div>
</DialogPanel>
</div>
</Dialog>
</Transition>
);
}