mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Added measures table
This commit is contained in:
parent
9c078464fb
commit
ee022b83c4
3 changed files with 509 additions and 23 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue