Merge github.com:Hestia-Homes/assessment-model into feature/guiness_live_tracking

This commit is contained in:
Jun-te Kim 2026-02-20 13:27:56 +00:00
commit 5d77576269
21 changed files with 6888 additions and 370 deletions

View file

@ -35,7 +35,6 @@ const PresignedUrlBodySchema = z
export async function POST(request: NextRequest) {
// For the moment, this api specifically handles uploads of csvs
console.log("Triggering plan build");
const body = await request.json();
let validatedBody;
@ -78,7 +77,7 @@ export async function POST(request: NextRequest) {
JSON.stringify({ msg: "Error triggering plan" }),
{
status: 500,
}
},
);
}

View file

@ -2,8 +2,13 @@ import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
type BaselineAggregates = {
/* =======================
Types
======================= */
type ScenarioAggregates = {
n_units: number;
avg_sap: number | null;
avg_carbon: number | null;
@ -11,7 +16,6 @@ type BaselineAggregates = {
total_carbon: number | null;
total_bills: number | null;
total_sap_uplift: number | null;
sap_points_array: (number | null)[];
};
type UpgradedAggregates = {
@ -21,120 +25,243 @@ type UpgradedAggregates = {
total_funding: number | null;
};
type PortfolioAggregates = {
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
};
type EpcRow = {
effective_sap: number | null;
};
/* =======================
Constants
======================= */
const EPC_MIN_SAP: Record<string, number> = {
A: 92,
B: 81,
C: 69,
D: 55,
E: 39,
F: 21,
G: 0,
};
/* =======================
Route
======================= */
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
props: { params: Promise<{ portfolioId: string; scenarioId: string }> },
) {
const { portfolioId, scenarioId } = await props.params;
const pid = BigInt(portfolioId);
const sid = BigInt(scenarioId);
//
// ----------------------------------------------------------
// QUERY 1 — Baseline metrics for *all* properties
// ----------------------------------------------------------
//
const baselineResult = 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
)
SELECT
COUNT(*)::int AS n_units,
AVG(lp.post_sap_points)::float AS avg_sap,
AVG(lp.post_co2_emissions)::float AS avg_carbon,
AVG(lp.post_energy_bill)::float AS avg_bills,
SUM(lp.post_co2_emissions)::float AS total_carbon,
SUM(lp.post_energy_bill)::float AS total_bills,
SUM(
CASE
WHEN lp.cost_of_works > 0.01
AND p.current_sap_points IS NOT NULL
AND lp.post_sap_points IS NOT NULL
THEN lp.post_sap_points - p.current_sap_points
ELSE 0
END
)::float AS total_sap_uplift,
ARRAY_AGG(lp.post_sap_points) AS sap_points_array
FROM latest_plans lp
JOIN property p
ON p.id = lp.property_id;
`);
const baseline = baselineResult.rows[0] as BaselineAggregates | undefined;
if (!baseline || baseline.n_units === 0) {
return NextResponse.json(
{ error: "No plans found for this scenario" },
{ status: 404 }
);
if (!scenarioId || scenarioId === "null") {
return NextResponse.json({ error: "Invalid scenarioId" }, { status: 400 });
}
const {
n_units,
avg_sap,
avg_carbon,
avg_bills,
total_carbon,
total_bills,
sap_points_array,
} = baseline;
const pid = BigInt(portfolioId);
const sid = BigInt(scenarioId);
const hideNonCompliant =
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
//
// ----------------------------------------------------------
// QUERY 2 — Upgrade metrics for properties receiving work
// ----------------------------------------------------------
//
/* ----------------------------------------------------------
Query 0 scenario definition
---------------------------------------------------------- */
const scenarioResult = await db.execute(sql`
SELECT goal, goal_value
FROM scenario
WHERE id = ${sid}
AND portfolio_id = ${pid}
LIMIT 1
`);
const scenario = scenarioResult.rows[0] as
| { goal: PortfolioGoalType; goal_value: string }
| undefined;
if (!scenario) {
return NextResponse.json({ error: "Scenario not found" }, { status: 404 });
}
const minSap =
scenario.goal === "Increasing EPC"
? EPC_MIN_SAP[scenario.goal_value]
: null;
/* ----------------------------------------------------------
QUERY 1 Scenario metrics (PLANS ONLY)
---------------------------------------------------------- */
const scenarioMetricsResult = await db.execute(sql`
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
AND (
${hideNonCompliant} = false
OR (
${minSap}::float IS NOT NULL
AND post_sap_points >= ${minSap}::float
)
)
ORDER BY property_id, created_at DESC
)
SELECT
COUNT(*)::int AS n_units,
AVG(post_sap_points)::float AS avg_sap,
AVG(post_co2_emissions)::float AS avg_carbon,
AVG(post_energy_bill)::float AS avg_bills,
SUM(post_co2_emissions)::float AS total_carbon,
SUM(post_energy_bill)::float AS total_bills,
SUM(
CASE
WHEN cost_of_works > 0
AND post_sap_points IS NOT NULL
THEN post_sap_points - p.current_sap_points
ELSE 0
END
)::float AS total_sap_uplift
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id;
`);
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
/* ----------------------------------------------------------
QUERY 1b Upgrade costs (PLANS ONLY)
---------------------------------------------------------- */
const upgradedResult = 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
)
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;
`);
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
*
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid}
AND (
${hideNonCompliant} = false
OR (
${minSap}::float IS NOT NULL
AND post_sap_points >= ${minSap}::float
)
)
ORDER BY property_id, created_at DESC
)
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;
`);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
const n_units_upgraded = upgraded.n_units_upgraded;
const construction_cost = upgraded.total_cost ?? 0;
const contingency = upgraded.contingency ?? 0;
const total_funding = upgraded.total_funding ?? 0;
const net_cost = construction_cost - total_funding;
const pc_cost = construction_cost * 0.3; // Placeholder for PC cost
const total_sap_uplift = baseline.total_sap_uplift ?? 0;
/* ----------------------------------------------------------
QUERY 2 Portfolio AFTER scenario (ALL properties)
---------------------------------------------------------- */
const portfolioMetricsResult = await db.execute(sql`
SELECT
AVG(effective_sap)::float AS avg_sap,
AVG(effective_carbon)::float AS avg_carbon,
AVG(effective_bills)::float AS avg_bills,
SUM(effective_carbon)::float AS total_carbon,
SUM(effective_bills)::float AS total_bills
FROM (
SELECT
/* ---------- SAP ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
ELSE p.current_sap_points
END AS effective_sap,
/* ---------- Carbon ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
ELSE e.co2_emissions
END AS effective_carbon,
/* ---------- Bills ---------- */
CASE
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
ELSE (
e.heating_cost_current +
e.hot_water_cost_current +
e.lighting_cost_current +
e.appliances_cost_current +
e.gas_standing_charge +
e.electricity_standing_charge -
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
)
END AS effective_bills
FROM property p
LEFT JOIN property_details_epc e
ON e.property_id = p.id
LEFT JOIN LATERAL (
SELECT *
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.scenario_id = ${sid}
AND (
${hideNonCompliant} = false
OR (
${minSap}::float IS NOT NULL
AND plan.post_sap_points >= ${minSap}::float
)
)
ORDER BY created_at DESC
LIMIT 1
) lp ON true
WHERE p.portfolio_id = ${pid}
) q;
`);
const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates;
/* ----------------------------------------------------------
QUERY 3 EPC band distribution (ALL properties)
---------------------------------------------------------- */
const epcRows = await db.execute(sql`
SELECT
CASE
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
ELSE p.current_sap_points
END AS effective_sap
FROM property p
LEFT JOIN LATERAL (
SELECT *
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.scenario_id = ${sid}
AND (
${hideNonCompliant} = false
OR (
${minSap}::float IS NOT NULL
AND plan.post_sap_points >= ${minSap}::float
)
)
ORDER BY created_at DESC
LIMIT 1
) lp ON true
WHERE p.portfolio_id = ${pid};
`);
//
// ----------------------------------------------------------
// EPC band distribution (all properties)
// ----------------------------------------------------------
//
const scenario_epc_counts: Record<string, number> = {
A: 0,
B: 0,
@ -146,36 +273,43 @@ JOIN property p
Unknown: 0,
};
for (const sap of sap_points_array) {
const band = sapToEpc(sap);
for (const row of epcRows.rows as EpcRow[]) {
const band = sapToEpc(row.effective_sap);
scenario_epc_counts[band] += 1;
}
//
// ----------------------------------------------------------
// RESPONSE
// ----------------------------------------------------------
//
/* ----------------------------------------------------------
RESPONSE
---------------------------------------------------------- */
const constructionCost = upgraded.total_cost ?? 0;
const nUpgraded = upgraded.n_units_upgraded ?? 0;
const pc_cost = constructionCost * 0.3;
return NextResponse.json({
// Baseline metrics (all units)
avg_sap: avg_sap !== null ? Number(avg_sap).toFixed(1) : null,
avg_carbon,
avg_bills,
total_carbon,
total_bills,
n_units,
/* -------- portfolio-after-scenario -------- */
avg_sap:
portfolioAgg.avg_sap !== null
? Number(portfolioAgg.avg_sap).toFixed(1)
: null,
avg_carbon: portfolioAgg.avg_carbon,
avg_bills: portfolioAgg.avg_bills,
total_carbon: portfolioAgg.total_carbon,
total_bills: portfolioAgg.total_bills,
/* -------- scenario-only -------- */
n_units: scenarioAgg.n_units,
n_units_upgraded: nUpgraded,
construction_cost: constructionCost,
contingency: upgraded.contingency ?? 0,
total_funding: upgraded.total_funding ?? 0,
net_cost: constructionCost - (upgraded.total_funding ?? 0),
total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0,
gross_per_unit:
nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0,
/* -------- shared -------- */
scenario_epc_counts,
pc_cost,
// Upgrade metrics (only properties with work)
n_units_upgraded,
construction_cost,
contingency,
total_funding,
net_cost,
gross_per_unit:
n_units_upgraded > 0
? (construction_cost + pc_cost) / n_units_upgraded
: 0,
total_sap_uplift,
});
}

View file

@ -6,7 +6,10 @@ import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils";
import { eq } from "drizzle-orm";
import { scenario } from "@/app/db/schema/recommendations";
export async function GET(request: NextRequest, props: { params: Promise<{ scenarioId: string }> }) {
export async function GET(
request: NextRequest,
props: { params: Promise<{ scenarioId: string }> },
) {
const params = await props.params;
const scenarioId = params.scenarioId;
@ -50,7 +53,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena
{
scenarioName: scenarioName,
data: JSON.parse(
data[0].epcBreakdownPostRetrofit || "[]"
data[0].epcBreakdownPostRetrofit || "[]",
) as ChartData[],
},
],
@ -114,29 +117,49 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena
scenarios: [
{ scenarioName: scenarioName, data: data[0].costPerUnit || "" },
],
},
},
{
title: "Funding (£)",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber(data[0].funding || 0) },
{
scenarioName: scenarioName,
data: "£" + formatNumber(data[0].funding || 0),
},
],
},
{
title: "Funding (£)/unit",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)) },
{
scenarioName: scenarioName,
data:
"£" +
formatNumber(
(data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1),
),
},
],
},
{
title: "Contingency (£)",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber(data[0].contingency || 0) },
{
scenarioName: scenarioName,
data: "£" + formatNumber(data[0].contingency || 0),
},
],
},
{
title: "Contingency (£)/unit",
scenarios: [
{ scenarioName: scenarioName, data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)) },
{
scenarioName: scenarioName,
data:
"£" +
formatNumber(
(data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1),
),
},
],
},
{

View file

@ -29,6 +29,7 @@ const TitleMap = {
loft_insulation: "Loft Insulation",
room_roof_insulation: "Room Roof Insulation",
flat_roof_insulation: "Flat Roof Insulation",
sloping_ceiling_insulation: "Sloping Ceiling Insulation",
// Floor
solid_floor_insulation: "Solid Floor Insulation",
suspended_floor_insulation: "Suspended Floor Insulation",
@ -104,12 +105,12 @@ export default function RecommendationCard({
setTotalKwhSavings,
}: RecommendationCardProps) {
const defaultComponent = recommendationData.find(
(rec: Recommendation) => rec.default
(rec: Recommendation) => rec.default,
) as Recommendation;
// A recommendation type could have no default recommendation, so we need to check if it exists
const alreadyInstalled = recommendationData.some(
(rec) => rec.alreadyInstalled
(rec) => rec.alreadyInstalled,
);
const [cardComponent, setCardComponent] =

View file

@ -0,0 +1,30 @@
CREATE TYPE "public"."aspect_type" AS ENUM('material', 'condition', 'type', 'area', 'configuration', 'presence', 'risk', 'severity', 'location', 'finish', 'insulation', 'pointing', 'spalling', 'lintels', 'cladding', 'category', 'quantity', 'adequacy', 'rating', 'strategy', 'extent', 'distribution', 'structure', 'covering', 'fire_rating', 'external_decoration', 'work_required', 'age_band', 'construction_type', 'classification', 'system');--> statement-breakpoint
CREATE TYPE "public"."element_type" AS ENUM('property', 'property_construction_type', 'property_classification', 'property_age_band', 'storey_count', 'floor_level', 'floor_level_front_door', 'accessible_housing_register', 'asbestos', 'quality_standard', 'ccu', 'passenger_lift', 'stairlift', 'disabled_hoist_tracking', 'disabled_facilities', 'steps_to_front_door', 'roof', 'pitched_roof_covering', 'flat_roof_covering', 'rainwater_goods', 'loft_insulation', 'porch_canopy', 'chimney', 'fascia', 'soffit', 'fascia_soffit_bargeboards', 'gutters', 'store_roof', 'garage_roof', 'garage_and_store_roof', 'external_wall', 'external_noise_insulation', 'primary_wall', 'secondary_wall', 'downpipes', 'external_decoration', 'cladding', 'spandrel_panels', 'garage_walls', 'party_wall_fire_break', 'external_brickwork_pointing', 'internal_downpipes_external_area', 'external_windows', 'communal_windows', 'secondary_glazing', 'store_windows', 'garage_windows', 'garage_and_store_windows', 'external_door', 'front_door', 'rear_door', 'store_door', 'garage_door', 'garage_and_store_door', 'communal_entrance_door', 'main_door', 'block_entrance_door', 'lintel', 'patio_french_door', 'door_entry_handset', 'paths_and_hardstandings', 'parking_areas', 'boundary_walls', 'front_fencing', 'rear_fencing', 'side_fencing', 'rear_gate', 'front_gate', 'gates', 'retaining_walls', 'private_balcony', 'balcony_balustrade', 'outbuildings', 'garage_structure', 'paving', 'roads', 'soil_and_vent', 'solar_thermals', 'drop_kerb', 'outbuilding_overhaul', 'external_structural_defects', 'access_ramp', 'kitchen', 'kitchen_space_layout', 'tenant_installed_kitchen', 'kitchen_extractor_fan', 'bathroom', 'secondary_bathroom', 'secondary_toilet', 'bathroom_extractor_fan', 'additional_wc_or_whb', 'bathroom_remaining_life_source', 'kitchen_remaining_life_source', 'central_heating', 'heating_boiler', 'heating_distribution', 'secondary_heating', 'hot_water_system', 'cold_water_storage', 'heating_system', 'boiler_fuel', 'water_heating', 'programmable_heating', 'community_heating', 'gas_available', 'heat_recovery_units', 'heating_improvements', 'electrical_wiring', 'consumer_unit', 'smoke_detection', 'heat_detection', 'carbon_monoxide_detection', 'fire_door_rating', 'fire_risk_assessment', 'internal_wiring', 'electrics', 'communal_heating', 'communal_boiler', 'communal_electrics', 'communal_fire_alarm', 'communal_emergency_lighting', 'communal_door_entry', 'communal_cctv', 'communal_bin_store', 'communal_bin_store_doors', 'communal_bin_store_walls', 'communal_bin_store_roof', 'communal_refuse_chute', 'communal_floor_covering', 'communal_kitchen', 'communal_bathroom', 'communal_toilets', 'communal_gates', 'communal_lift', 'communal_passenger_lift', 'communal_balcony_walkway', 'communal_entrance', 'communal_internal_decorations', 'communal_internal_floor', 'communal_walkways', 'communal_external_doors', 'communal_stairs', 'communal_aerial', 'communal_aov', 'communal_internal_doors', 'communal_lateral_mains', 'communal_lighting', 'communal_lighting_conductor', 'communal_store_roof', 'communal_store_walls', 'communal_store_doors', 'communal_warden_call_system', 'communal_bms', 'communal_booster_pump', 'communal_dry_riser', 'communal_wet_riser', 'communal_cold_water_storage', 'communal_sprinkler', 'communal_plug_sockets', 'communal_circulation_space', 'ffhh_damp', 'ffhh_hold_and_cold_water', 'ffhh_drainage_lavatories', 'ffhh_neglected', 'ffhh_natural_light', 'ffhh_ventilation', 'ffhh_food_prep_and_washup', 'ffhh_unsafe_layout', 'ffhh_unstable_building', 'hhsrs_damp_and_mould', 'hhsrs_excess_cold', 'hhsrs_excess_heat', 'hhsrs_asbestos_and_mmf', 'hhsrs_biocides', 'hhsrs_carbon_monoxide', 'hhsrs_lead', 'hhsrs_radiation', 'hhsrs_uncombusted_fuel_gas', 'hhsrs_volatile_organic_compounds', 'hhsrs_crowding_and_space', 'hhsrs_entry_by_intruders', 'hhsrs_lighting', 'hhsrs_noise', 'hhsrs_domestic_hygiene_pests_refuse', 'hhsrs_food_safety', 'hhsrs_personal_hygiene_sanitation', 'hhsrs_water_supply', 'hhsrs_falls_associated_with_baths', 'hhsrs_falls_on_level_surfaces', 'hhsrs_falls_on_stairs', 'hhsrs_falls_between_levels', 'hhsrs_electrical_hazards', 'hhsrs_fire', 'hhsrs_flames_hot_surfaces', 'hhsrs_collision_and_entrapment', 'hhsrs_collision_hazards_low_headroom', 'hhsrs_explosions', 'hhsrs_ergonomics', 'hhsrs_structural_collapse', 'hhsrs_amenities');--> statement-breakpoint
CREATE TABLE "aspect_condition" (
"id" bigserial PRIMARY KEY NOT NULL,
"element_id" bigint NOT NULL,
"aspect_type" "aspect_type" NOT NULL,
"aspect_instance" integer NOT NULL,
"value" text,
"quantity" integer,
"install_date" date,
"renewal_year" integer,
"comments" text
);
--> statement-breakpoint
CREATE TABLE "element" (
"id" bigserial PRIMARY KEY NOT NULL,
"survey_id" bigint NOT NULL,
"element_type" "element_type" NOT NULL,
"element_instance" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE "property_condition_survey" (
"id" bigserial PRIMARY KEY NOT NULL,
"uprn" bigint NOT NULL,
"date" date NOT NULL,
"source" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "aspect_condition" ADD CONSTRAINT "aspect_condition_element_id_element_id_fk" FOREIGN KEY ("element_id") REFERENCES "public"."element"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "element" ADD CONSTRAINT "element_survey_id_property_condition_survey_id_fk" FOREIGN KEY ("survey_id") REFERENCES "public"."property_condition_survey"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -1044,6 +1044,13 @@
"when": 1767823836420,
"tag": "0148_first_gamma_corps",
"breakpoints": true
},
{
"idx": 149,
"version": "7",
"when": 1769597155526,
"tag": "0149_rich_luminals",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,28 @@
import { bigint, bigserial, date, integer, pgTable, text } from "drizzle-orm/pg-core";
import { element } from "./element";
import { aspectType } from "./aspect_type";
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
export const aspectCondition = pgTable("aspect_condition", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
elementId: bigint("element_id", { mode: "number" })
.notNull()
.references(() => element.id),
aspectType: aspectType("aspect_type").notNull(),
aspectInstance: integer("aspect_instance").notNull(),
value: text("value"),
quantity: integer("quantity"),
installDate: date("install_date"),
renewalYear: integer("renewal_year"),
comments: text("comments"),
});
export type AspectConditionRow =
InferSelectModel<typeof aspectCondition>;
export type NewAspectConditionRow =
InferInsertModel<typeof aspectCondition>;

View file

@ -0,0 +1,37 @@
import { pgEnum } from "drizzle-orm/pg-core";
export const aspectType = pgEnum("aspect_type", [
"material",
"condition",
"type",
"area",
"configuration",
"presence",
"risk",
"severity",
"location",
"finish",
"insulation",
"pointing",
"spalling",
"lintels",
"cladding",
"category",
"quantity",
"adequacy",
"rating",
"strategy",
"extent",
"distribution",
"structure",
"covering",
"fire_rating",
"external_decoration",
"work_required",
"age_band",
"construction_type",
"classification",
"system",
]);
export type AspectType = typeof aspectType.enumValues[number];

View file

@ -0,0 +1,19 @@
import { bigint, bigserial, integer, pgTable } from "drizzle-orm/pg-core";
import { propertyConditionSurvey } from "./property_condition_survey";
import { elementType } from "./element_type";
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
export const element = pgTable("element", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
surveyId: bigint("survey_id", { mode: "number" })
.notNull()
.references(() => propertyConditionSurvey.id),
elementType: elementType("element_type").notNull(),
elementInstance: integer("element_instance").notNull(),
});
export type ElementRow = InferSelectModel<typeof element>;
export type NewElementRow = InferInsertModel<typeof element>;

View file

@ -0,0 +1,206 @@
import { pgEnum } from "drizzle-orm/pg-core";
export const elementType = pgEnum("element_type", [
"property",
"property_construction_type",
"property_classification",
"property_age_band",
"storey_count",
"floor_level",
"floor_level_front_door",
"accessible_housing_register",
"asbestos",
"quality_standard",
"ccu",
"passenger_lift",
"stairlift",
"disabled_hoist_tracking",
"disabled_facilities",
"steps_to_front_door",
"roof",
"pitched_roof_covering",
"flat_roof_covering",
"rainwater_goods",
"loft_insulation",
"porch_canopy",
"chimney",
"fascia",
"soffit",
"fascia_soffit_bargeboards",
"gutters",
"store_roof",
"garage_roof",
"garage_and_store_roof",
"external_wall",
"external_noise_insulation",
"primary_wall",
"secondary_wall",
"downpipes",
"external_decoration",
"cladding",
"spandrel_panels",
"garage_walls",
"party_wall_fire_break",
"external_brickwork_pointing",
"internal_downpipes_external_area",
"external_windows",
"communal_windows",
"secondary_glazing",
"store_windows",
"garage_windows",
"garage_and_store_windows",
"external_door",
"front_door",
"rear_door",
"store_door",
"garage_door",
"garage_and_store_door",
"communal_entrance_door",
"main_door",
"block_entrance_door",
"lintel",
"patio_french_door",
"door_entry_handset",
"paths_and_hardstandings",
"parking_areas",
"boundary_walls",
"front_fencing",
"rear_fencing",
"side_fencing",
"rear_gate",
"front_gate",
"gates",
"retaining_walls",
"private_balcony",
"balcony_balustrade",
"outbuildings",
"garage_structure",
"paving",
"roads",
"soil_and_vent",
"solar_thermals",
"drop_kerb",
"outbuilding_overhaul",
"external_structural_defects",
"access_ramp",
"kitchen",
"kitchen_space_layout",
"tenant_installed_kitchen",
"kitchen_extractor_fan",
"bathroom",
"secondary_bathroom",
"secondary_toilet",
"bathroom_extractor_fan",
"additional_wc_or_whb",
"bathroom_remaining_life_source",
"kitchen_remaining_life_source",
"central_heating",
"heating_boiler",
"heating_distribution",
"secondary_heating",
"hot_water_system",
"cold_water_storage",
"heating_system",
"boiler_fuel",
"water_heating",
"programmable_heating",
"community_heating",
"gas_available",
"heat_recovery_units",
"heating_improvements",
"electrical_wiring",
"consumer_unit",
"smoke_detection",
"heat_detection",
"carbon_monoxide_detection",
"fire_door_rating",
"fire_risk_assessment",
"internal_wiring",
"electrics",
"communal_heating",
"communal_boiler",
"communal_electrics",
"communal_fire_alarm",
"communal_emergency_lighting",
"communal_door_entry",
"communal_cctv",
"communal_bin_store",
"communal_bin_store_doors",
"communal_bin_store_walls",
"communal_bin_store_roof",
"communal_refuse_chute",
"communal_floor_covering",
"communal_kitchen",
"communal_bathroom",
"communal_toilets",
"communal_gates",
"communal_lift",
"communal_passenger_lift",
"communal_balcony_walkway",
"communal_entrance",
"communal_internal_decorations",
"communal_internal_floor",
"communal_walkways",
"communal_external_doors",
"communal_stairs",
"communal_aerial",
"communal_aov",
"communal_internal_doors",
"communal_lateral_mains",
"communal_lighting",
"communal_lighting_conductor",
"communal_store_roof",
"communal_store_walls",
"communal_store_doors",
"communal_warden_call_system",
"communal_bms",
"communal_booster_pump",
"communal_dry_riser",
"communal_wet_riser",
"communal_cold_water_storage",
"communal_sprinkler",
"communal_plug_sockets",
"communal_circulation_space",
"ffhh_damp",
"ffhh_hold_and_cold_water",
"ffhh_drainage_lavatories",
"ffhh_neglected",
"ffhh_natural_light",
"ffhh_ventilation",
"ffhh_food_prep_and_washup",
"ffhh_unsafe_layout",
"ffhh_unstable_building",
"hhsrs_damp_and_mould",
"hhsrs_excess_cold",
"hhsrs_excess_heat",
"hhsrs_asbestos_and_mmf",
"hhsrs_biocides",
"hhsrs_carbon_monoxide",
"hhsrs_lead",
"hhsrs_radiation",
"hhsrs_uncombusted_fuel_gas",
"hhsrs_volatile_organic_compounds",
"hhsrs_crowding_and_space",
"hhsrs_entry_by_intruders",
"hhsrs_lighting",
"hhsrs_noise",
"hhsrs_domestic_hygiene_pests_refuse",
"hhsrs_food_safety",
"hhsrs_personal_hygiene_sanitation",
"hhsrs_water_supply",
"hhsrs_falls_associated_with_baths",
"hhsrs_falls_on_level_surfaces",
"hhsrs_falls_on_stairs",
"hhsrs_falls_between_levels",
"hhsrs_electrical_hazards",
"hhsrs_fire",
"hhsrs_flames_hot_surfaces",
"hhsrs_collision_and_entrapment",
"hhsrs_collision_hazards_low_headroom",
"hhsrs_explosions",
"hhsrs_ergonomics",
"hhsrs_structural_collapse",
"hhsrs_amenities",
]);
export type ElementType = typeof elementType.enumValues[number];

View file

@ -0,0 +1,23 @@
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
import {
pgTable,
bigserial,
bigint,
date,
text,
} from "drizzle-orm/pg-core";
export const propertyConditionSurvey = pgTable("property_condition_survey", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "number" }).notNull(),
date: date("date").notNull(),
source: text("source").notNull(),
});
export type PropertyConditionSurveyRow =
InferSelectModel<typeof propertyConditionSurvey>;
export type NewPropertyConditionSurveyRow =
InferInsertModel<typeof propertyConditionSurvey>;

View file

@ -31,6 +31,14 @@ export const PortfolioGoal: [string, ...string[]] = [
"Energy Savings",
"None",
];
export type PortfolioGoalType = (typeof PortfolioGoal)[number];
export const PORTFOLIO_GOALS = {
EPC: "Increasing EPC",
VALUATION: "Valuation Improvement",
CO2: "Reducing CO2 emissions",
ENERGY: "Energy Savings",
NONE: "None",
} satisfies Record<string, PortfolioGoalType>;
export const PortfolioRole: [string, ...string[]] = [
"creator",
@ -79,10 +87,10 @@ export const portfolio = pgTable("portfolio", {
energyBillPerUnitPreRetrofit: text("energy_bill_per_unit_pre_retrofit"),
energyBillPerUnitPostRetrofit: text("energy_bill_per_unit_post_retrofit"),
energyConsumptionPerUnitPreRetrofit: text(
"energy_consumption_per_unit_pre_retrofit"
"energy_consumption_per_unit_pre_retrofit",
),
energyConsumptionPerUnitPostRetrofit: text(
"energy_consumption_per_unit_post_retrofit"
"energy_consumption_per_unit_post_retrofit",
),
valuationImprovementPerUnit: text("valuation_improvement_per_unit"),
costPerUnit: text("cost_per_unit"),

View file

@ -9,23 +9,29 @@ import {
} from "@/app/shadcn_components/ui/card";
import { motion } from "framer-motion";
import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react";
import { formatNumber } from "@/app/utils";
import { formatNumber, sapToEpc } from "@/app/utils";
import type {
AverageMetrics,
EstimatedCounts,
TotalMetrics,
ScenarioOverlayMetrics,
MetricKey,
} from "./types";
import type { MetricKey } from "./types";
import { sapToEpc } from "@/app/utils";
const cardStyles = {
/* ───────────────────────────────────────────── */
/* Style maps */
/* ───────────────────────────────────────────── */
const cardStyles: Record<
MetricKey,
{ icon: React.ComponentType<any>; color: string }
> = {
totalHomes: { icon: Home, color: "text-purple-600" },
avgSap: { icon: LineChart, color: "text-blue-600" },
avgCarbon: { icon: Leaf, color: "text-emerald-600" },
avgBills: { icon: Zap, color: "text-amber-600" },
missingEpc: { icon: FileQuestionIcon, color: "text-red-600" },
} as Record<MetricKey, { icon: React.ComponentType<any>; color: string }>;
};
const epcColors: Record<string, string> = {
A: "text-epc_a",
@ -38,24 +44,38 @@ const epcColors: Record<string, string> = {
Unknown: "text-gray-400",
};
/* ───────────────────────────────────────────── */
/* Helpers */
/* ───────────────────────────────────────────── */
function hasOverlay(
overlay: ScenarioOverlayMetrics | undefined
overlay: ScenarioOverlayMetrics | undefined,
): overlay is ScenarioOverlayMetrics {
return overlay !== undefined;
}
function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded bg-gray-200 ${className}`} />;
}
/* ───────────────────────────────────────────── */
/* Component */
/* ───────────────────────────────────────────── */
export function DashboardSummaryCards({
total,
totals,
averages,
estimatedCounts,
scenarioOverlay,
loading = false,
}: {
total: number;
totals: TotalMetrics;
averages: AverageMetrics;
estimatedCounts: EstimatedCounts;
scenarioOverlay?: ScenarioOverlayMetrics | null;
loading?: boolean;
}) {
const missingEpcCount = estimatedCounts.estimated;
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
@ -66,10 +86,7 @@ export function DashboardSummaryCards({
const hasScenario = hasOverlay(overlay);
function deltaLabel(baseline: number, scenario: number) {
const b = Number(baseline);
const s = Number(scenario);
const diff = s - b;
const diff = scenario - baseline;
if (!isFinite(diff) || diff === 0) return null;
const sign = diff > 0 ? "▲" : "▼";
@ -87,10 +104,6 @@ export function DashboardSummaryCards({
key: "totalHomes",
title: "Number of Homes",
baseline: total,
scenario: null,
baselineTotal: undefined,
scenarioTotal: undefined,
units: "",
subtitle: "Total properties in this portfolio.",
},
{
@ -100,8 +113,6 @@ export function DashboardSummaryCards({
scenario:
overlay?.avgSap &&
`${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`,
baselineTotal: undefined,
scenarioTotal: undefined,
subtitle: "Current SAP rating across all properties.",
isEpc: true,
},
@ -144,92 +155,110 @@ export function DashboardSummaryCards({
return (
<Card
key={c.key}
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
className="h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
>
{/* Header */}
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<motion.div whileHover={{ scale: 1.05 }}>
<Icon className={`h-5 w-5 ${color}`} />
</motion.div>
<CardTitle className="text-md font-medium text-gray-700">
{c.title}
</CardTitle>
{loading ? (
<>
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-4 w-32" />
</>
) : (
<>
<motion.div whileHover={{ scale: 1.05 }}>
<Icon className={`h-5 w-5 ${color}`} />
</motion.div>
<CardTitle className="text-md font-medium text-gray-700">
{c.title}
</CardTitle>
</>
)}
</CardHeader>
{/* Content */}
<CardContent className="flex flex-1 flex-col gap-2">
{/* BASELINE + SCENARIO ROW */}
<div
className={`flex ${
hasScenario ? "justify-between" : "justify-start"
} items-start`}
>
{/* BASELINE COLUMN */}
{/* Baseline */}
<div className="flex flex-col">
<span className="text-xs text-gray-500">Baseline</span>
<div className="flex items-baseline gap-2">
<span
className={
c.isEpc
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue print-text-solid"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
</span>
{/* units next to baseline average */}
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
{/* Baseline total */}
{c.baselineTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
{loading ? (
<Skeleton className="h-8 w-28 mt-1" />
) : (
<div className="flex items-baseline gap-2">
<span
className={
c.isEpc
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
</span>
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
)}
{c.baselineTotal !== undefined &&
(loading ? (
<Skeleton className="h-4 w-36 mt-1" />
) : (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
))}
</div>
{/* SCENARIO COLUMN */}
{/* Scenario */}
{hasScenario && c.scenario && (
<div className="flex flex-col text-right">
<span className="text-xs text-gray-500">Scenario</span>
{/* average + delta + units row */}
<div className="flex items-baseline justify-end gap-2">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
overlay?.avgSap?.scenario ??
(averages.avg_sap || 0)
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
{c.delta && <span>{c.delta}</span>}
</div>
{/* Scenario total */}
{c.scenarioTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
{loading ? (
<Skeleton className="h-7 w-24 mt-1 ml-auto" />
) : (
<div className="flex items-baseline justify-end gap-2">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
overlay?.avgSap?.scenario ??
(averages.avg_sap || 0),
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
{c.delta && <span>{c.delta}</span>}
</div>
)}
{c.scenarioTotal !== undefined &&
(loading ? (
<Skeleton className="h-4 w-36 mt-1 ml-auto" />
) : (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
))}
</div>
)}
</div>
@ -246,7 +275,11 @@ export function DashboardSummaryCards({
</CardContent>
<CardFooter>
<p className="text-xs text-gray-500">{c.subtitle}</p>
{loading ? (
<Skeleton className="h-3 w-3/4" />
) : (
<p className="text-xs text-gray-500">{c.subtitle}</p>
)}
</CardFooter>
</Card>
);

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
import { DashboardSummaryCards } from "./DashboardSummaryCards";
@ -21,6 +21,7 @@ import type {
PropertyTypeCount,
ScenarioSummary,
} from "./types";
import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons";
interface ReportingClientAreaProps {
baseline: BaselineMetrics;
@ -35,12 +36,17 @@ interface ReportingClientAreaProps {
async function fetchScenarioReport({
portfolioId,
scenarioId,
hideNonCompliant,
}: {
portfolioId: number;
scenarioId: number;
hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/
}) {
const params = new URLSearchParams({
hideNonCompliant: String(hideNonCompliant),
});
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`,
);
if (!res.ok) {
console.error("Failed to fetch scenario report:", await res.text());
@ -57,7 +63,7 @@ async function fetchScenarioMeasures({
scenarioId: number;
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`,
);
if (!res.ok) {
@ -74,9 +80,11 @@ export function ReportingClientArea({
portfolioId,
}: ReportingClientAreaProps) {
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
null
null,
);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -86,15 +94,24 @@ export function ReportingClientArea({
const {
data: scenarioData,
isLoading,
isFetching,
isError,
} = useQuery({
queryKey: ["scenario-report", portfolioId, selectedScenarioId],
queryKey: [
"scenario-report",
portfolioId,
selectedScenarioId,
appliedHideNonCompliant,
],
queryFn: () =>
fetchScenarioReport({
portfolioId,
scenarioId: selectedScenarioId!,
hideNonCompliant: appliedHideNonCompliant,
}),
enabled: !!selectedScenarioId, // only run when scenario selected
keepPreviousData: true, // keep showing old data while loading new scenario or applying filter
refetchOnWindowFocus: false,
});
const {
@ -109,10 +126,10 @@ export function ReportingClientArea({
scenarioId: selectedScenarioId!,
}),
enabled: measuresOpen && !!selectedScenarioId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
const scenarioLoading = isLoading && !!selectedScenarioId;
// ----------------------------------------
// Build overlay for Dashboard Summary cards
// ----------------------------------------
@ -145,40 +162,45 @@ export function ReportingClientArea({
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
// ----------------------------------------
const scenarioSpecific = scenarioData
? {
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
scenarioData.total_sap_uplift
: 0,
costPerCo2:
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
: 0,
netCost: scenarioData.net_cost,
grossPerUnit: scenarioData.gross_per_unit,
nUnits: scenarioData.n_units_upgraded,
totalCarbonSaved:
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
totalBillsSaved:
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
averageCaribonSaved:
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
scenarioData.n_units_upgraded,
averageBillsSaved:
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
scenarioData.n_units_upgraded,
}
: null;
const scenarioSpecific = useMemo(() => {
if (!scenarioData) return null;
return {
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
scenarioData.total_sap_uplift
: 0,
costPerCo2:
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
: 0,
netCost: scenarioData.net_cost,
grossPerUnit: scenarioData.gross_per_unit,
nUnits: scenarioData.n_units_upgraded,
totalCarbonSaved:
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
totalBillsSaved:
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
averageCaribonSaved:
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
scenarioData.n_units_upgraded,
averageBillsSaved:
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
scenarioData.n_units_upgraded,
};
}, [scenarioData, baseline]);
// Baseline stays baseline
const activeMetrics = baseline;
const scenarioBusy = !!selectedScenarioId && (isLoading || isFetching);
return (
<>
<div className="flex items-center justify-between gap-4">
@ -193,33 +215,43 @@ export function ReportingClientArea({
{/* RIGHT: Actions (only when scenario selected) */}
{selectedScenarioId && (
<div className="flex items-center gap-2">
{/* Show measures */}
<button
onClick={() => setMeasuresOpen(true)}
disabled={scenarioLoading}
disabled={scenarioBusy}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
scenarioLoading
scenarioBusy
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
{scenarioLoading ? "Loading…" : "Show measures"}
{scenarioBusy ? "Loading…" : "Show measures"}
</button>
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioBusy}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}
/>
{/* Download PDF */}
<button
onClick={() => {
window.open(
`/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`,
"_blank"
"_blank",
);
}}
disabled={scenarioLoading}
disabled={scenarioBusy}
className={`
rounded-md border px-3 py-2 text-sm font-medium transition
${
scenarioLoading
scenarioBusy
? "border-gray-200 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}
@ -247,7 +279,11 @@ export function ReportingClientArea({
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
<ScenarioFinancialDrawer
open={drawerOpen}
metrics={scenarioSpecific}
loading={scenarioBusy}
/>
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
<DashboardSummaryCards
@ -256,6 +292,7 @@ export function ReportingClientArea({
averages={activeMetrics.averages}
estimatedCounts={activeMetrics.estimatedCounts}
scenarioOverlay={scenarioOverlay}
loading={scenarioBusy}
/>
<BreakdownChart

View file

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
export interface ReportingFunctionalityButtonsProps {
/** Currently applied value */
hideNonCompliant: boolean;
/**
* Explicit user action.
* Parent decides what "apply" means (refetch, mutate, etc).
*/
onApply: (value: boolean) => Promise<void> | void;
disabled?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
onApply,
disabled = false,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
const [isApplying, setIsApplying] = useState(false);
async function handleApply() {
try {
setIsApplying(true);
await onApply(draftHideNonCompliant);
} finally {
setIsApplying(false);
}
}
async function handleReset() {
try {
// reset the filter and trigger the fetch
setIsApplying(true);
setDraftHideNonCompliant(false);
await onApply(false);
} finally {
setIsApplying(false);
}
}
return (
<DropdownMenu
onOpenChange={(open) => {
if (open) {
setDraftHideNonCompliant(hideNonCompliant);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled || isApplying}
className={`
relative flex items-center gap-2
${
hideNonCompliant
? "border-brandmidblue/40 bg-brandlightblue/40"
: ""
}
`}
>
{/* Filter icon */}
<svg
className={`h-4 w-4 ${
hideNonCompliant ? "text-brandmidblue" : "text-gray-500"
}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M3 4a1 1 0 011-1h12a1 1 0 01.8 1.6l-4.8 6.4V16a1 1 0 01-1.447.894l-2-1A1 1 0 018 14v-2.999L3.2 5.6A1 1 0 013 4z" />
</svg>
Filter options
{hideNonCompliant && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-brandmidblue" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 p-4 shadow-tremor-dropdown"
>
<div className="space-y-5">
{/* Filter option */}
<div className="flex items-start gap-4">
<Checkbox
id="hide-non-compliant"
checked={draftHideNonCompliant}
onCheckedChange={(checked) =>
setDraftHideNonCompliant(Boolean(checked))
}
className="mt-1"
/>
<label
htmlFor="hide-non-compliant"
className="cursor-pointer space-y-1"
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug">
<svg
className="h-4 w-4 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 00-1.414 1.414L9 13l4.707-4.707z"
clipRule="evenodd"
/>
</svg>
Hide non-compliant properties
</div>
<div className="text-xs text-gray-500 leading-relaxed">
Exclude properties that dont meet the defined upgrade targets
</div>
</label>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
variant="ghost"
size="sm"
disabled={isApplying}
onClick={handleReset}
>
Reset
</Button>
<Button
size="sm"
className="bg-brandmidblue hover:bg-hoverblue"
disabled={isApplying}
onClick={handleApply}
>
{isApplying ? "Applying…" : "Apply filters"}
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -26,6 +26,7 @@ import { Gauge } from "lucide-react";
interface ScenarioFinancialDrawerProps {
open: boolean;
metrics: any | null;
loading?: boolean;
}
/* ───────────────────────────────────────────── */
@ -56,7 +57,7 @@ function GradientCard({
className={clsx(
"relative rounded-lg p-[2px] gradient-card",
gradient,
`gradient-${variant}`
`gradient-${variant}`,
)}
>
<div className="rounded-[7px] bg-white h-full">{children}</div>
@ -75,6 +76,7 @@ function Metric({
color,
gradient,
variant = "green",
loading = false,
}: {
label: string;
value: string | number;
@ -82,15 +84,38 @@ function Metric({
color: string;
gradient: string;
variant?: "green" | "blue" | "purple";
loading?: boolean;
}) {
if (loading || !value) {
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full animate-pulse">
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
<div className="h-6 w-3/4 bg-gray-200 rounded" />
</div>
</GradientCard>
);
}
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="flex flex-col items-center justify-center p-4 h-full text-center">
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">{value}</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
{loading ? (
<div className="w-full animate-pulse space-y-3">
<div className="h-6 w-6 mx-auto rounded bg-gray-200" />
<div className="h-8 w-2/3 mx-auto rounded bg-gray-200" />
<div className="h-3 w-1/2 mx-auto rounded bg-gray-200" />
</div>
) : (
<>
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">
{value}
</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
</>
)}
</div>
</GradientCard>
);
@ -108,6 +133,7 @@ function PairedMetric({
gradient,
iconClassName = "text-gray-700",
variant = "green",
loading = false,
}: {
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
@ -116,30 +142,62 @@ function PairedMetric({
gradient: string;
iconClassName?: string;
variant?: "green" | "blue" | "purple";
loading?: boolean;
}) {
if (loading || !primary.value || !secondary.value) {
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full animate-pulse">
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
<div className="h-6 w-3/4 bg-gray-200 rounded" />
</div>
</GradientCard>
);
}
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full">
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">{title}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
{loading ? (
<div className="animate-pulse space-y-4">
<div className="h-4 w-1/3 rounded bg-gray-200" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">
{title}
</span>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
</>
)}
</div>
</GradientCard>
);
@ -172,7 +230,7 @@ function Section({
<div
className={clsx(
"rounded-lg p-2 bg-white shadow-sm border",
accentColor
accentColor,
)}
>
<Icon className="h-5 w-5" />
@ -191,6 +249,22 @@ function Section({
);
}
/* ───────────────────────────────────────────── */
/* Loading Skeleton for dashboard cards */
/* ───────────────────────────────────────────── */
function LoadingOverlay() {
return (
<div className="absolute inset-0 z-20 rounded-lg bg-white/70 backdrop-blur-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-6 animate-pulse">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-28 rounded-lg bg-gray-200" />
))}
</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Main Drawer */
/* ───────────────────────────────────────────── */
@ -198,10 +272,11 @@ function Section({
export function ScenarioFinancialDrawer({
open,
metrics,
loading = false,
}: ScenarioFinancialDrawerProps) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
@ -228,14 +303,17 @@ export function ScenarioFinancialDrawer({
iconClassName="text-green-700"
primary={{
label: "Total carbon saved (t/yr)",
value: formatNumber(metrics.totalCarbonSaved),
value: metrics ? formatNumber(metrics.totalCarbonSaved) : "",
}}
secondary={{
label: "Average per unit (t/yr)",
value: formatNumber(metrics.averageCaribonSaved),
value: metrics
? formatNumber(metrics.averageCaribonSaved)
: "",
}}
gradient={gradients.green}
variant="green"
loading={loading}
/>
<PairedMetric
@ -244,23 +322,29 @@ export function ScenarioFinancialDrawer({
iconClassName="text-green-700"
primary={{
label: "Total bill savings (£/yr)",
value: `£${formatNumber(metrics.totalBillsSaved)}`,
value: metrics
? `£${formatNumber(metrics.totalBillsSaved)}`
: "",
}}
secondary={{
label: "Average per unit (£/yr)",
value: `£${formatNumber(metrics.averageBillsSaved)}`,
value: metrics
? `£${formatNumber(metrics.averageBillsSaved)}`
: "",
}}
gradient={gradients.green}
variant="green"
loading={loading}
/>
<Metric
label="Homes upgraded"
value={metrics.nUnits}
value={metrics ? metrics.nUnits : ""}
icon={HomeIcon}
color="text-green-700"
gradient={gradients.green}
variant="green"
loading={loading}
/>
</Section>
@ -278,32 +362,37 @@ export function ScenarioFinancialDrawer({
iconClassName="text-blue-600"
primary={{
label: "Construction works",
value: `£${formatNumber(metrics.constructionCost)}`,
value: metrics
? `£${formatNumber(metrics.constructionCost)}`
: "",
}}
secondary={{
label: "Project delivery",
value: `£${formatNumber(metrics.pcCost)}`,
value: metrics ? `£${formatNumber(metrics.pcCost)}` : "",
}}
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
<Metric
label="Gross cost per unit"
value={`£${formatNumber(metrics.grossPerUnit)}`}
value={metrics ? `£${formatNumber(metrics.grossPerUnit)}` : ""}
icon={HomeIcon}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
value={metrics ? `£${formatNumber(metrics.contingency)}` : ""}
icon={Gauge}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
</Section>
@ -321,14 +410,15 @@ export function ScenarioFinancialDrawer({
iconClassName="text-purple-700"
primary={{
label: "£ per SAP point",
value: `£${formatNumber(metrics.costPerSap)}`,
value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "",
}}
secondary={{
label: "£ per tonne CO₂",
value: `£${formatNumber(metrics.costPerCo2)}`,
value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "",
}}
gradient={gradients.purple}
variant="purple"
loading={loading}
/>
</Section>
</div>

View file

@ -23,7 +23,7 @@ export async function getPortfolioCounts(portfolioId: number): Promise<number> {
}
export async function getAverages(
portfolioId: number
portfolioId: number,
): Promise<AverageMetrics> {
const result = await db.execute<AverageMetrics>(sql`
SELECT
@ -69,7 +69,7 @@ export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
}
export async function getCountByAgeBand(
portfolioId: number
portfolioId: number,
): Promise<AgeBandCount[]> {
const result = await db.execute<AgeBandCount>(sql`
SELECT
@ -96,23 +96,27 @@ export async function getCountByAgeBand(
}
export async function getCountByEpcBand(
portfolioId: number
portfolioId: number,
): Promise<EpcBandCount[]> {
const result = await db.execute<EpcBandCount>(sql`
SELECT *
FROM (
SELECT
SELECT
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual,
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated
COUNT(*) FILTER (
WHERE e.estimated = false OR e.estimated IS NULL
)::int AS actual,
COUNT(*) FILTER (
WHERE e.estimated = true
)::int AS estimated
FROM property p
LEFT JOIN property_details_epc e
LEFT JOIN property_details_epc e
ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
GROUP BY epc
) q
ORDER BY
CASE
ORDER BY
CASE
WHEN q.epc = 'A' THEN 1
WHEN q.epc = 'B' THEN 2
WHEN q.epc = 'C' THEN 3
@ -120,7 +124,7 @@ export async function getCountByEpcBand(
WHEN q.epc = 'E' THEN 5
WHEN q.epc = 'F' THEN 6
WHEN q.epc = 'G' THEN 7
ELSE 8 -- 'Unknown'
ELSE 8
END;
`);
@ -128,7 +132,7 @@ export async function getCountByEpcBand(
}
export async function getEstimatedCounts(
portfolioId: number
portfolioId: number,
): Promise<EstimatedCounts> {
const result = await db.execute<EstimatedCounts>(sql`
SELECT
@ -142,7 +146,7 @@ export async function getEstimatedCounts(
}
export async function getCountByPropertyType(
portfolioId: number
portfolioId: number,
): Promise<PropertyTypeCount[]> {
const result = await db.execute<PropertyTypeCount>(sql`
SELECT property_type AS type, COUNT(*)::int AS count
@ -173,7 +177,7 @@ export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
}
export async function getLikelyDowngrades(
portfolioId: number
portfolioId: number,
): Promise<number> {
const result = await db.execute<{ downgrades: number }>(sql`
SELECT
@ -192,7 +196,7 @@ export async function getLikelyDowngrades(
}
export async function loadBaselineMetrics(
portfolioId: number
portfolioId: number,
): Promise<BaselineMetrics> {
const [
total,

View file

@ -106,7 +106,7 @@ const updateSettings = async ({
userId: userId.toString(),
action: "update",
}),
}
},
);
const permissionsData = await permissionsReponse.json();
@ -181,7 +181,7 @@ async function deletePortfolio({
userId: userId.toString(),
action: "delete",
}),
}
},
);
const permissionsData = await permissionsReponse.json();
@ -201,7 +201,7 @@ async function deletePortfolio({
if (!response.ok) {
throw new Error(
"deletePortfolio has been called into action but utterly failed to do the API handoff"
"deletePortfolio has been called into action but utterly failed to do the API handoff",
);
}
@ -241,13 +241,13 @@ export default function PortfolioSettings({
onError: (error) => {
console.error(
"Because the API hand off failed, we're right back here at the mutation station",
error
error,
);
},
});
const [portfolioName, setPortfolioName] = useState(
portfolioSettingsData.name
portfolioSettingsData.name,
);
const [portfolioBudget, setPortfolioBudget] = useState<
@ -255,11 +255,11 @@ export default function PortfolioSettings({
>(portfolioSettingsData.budget);
const [portfolioGoal, setPortfolioGoal] = useState(
portfolioSettingsData.goal
portfolioSettingsData.goal,
);
const [portfolioStatus, setPortfolioStatus] = useState(
portfolioSettingsData.status
portfolioSettingsData.status,
);
// Set up state for deleteModal and deleteConfirmation
@ -268,9 +268,13 @@ export default function PortfolioSettings({
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
if (session.status === "loading") {
// You can return a loading spinner or placeholder here
return <div>Loading...</div>;
}
if (!session.data) {
// The user is not logged in, redirect them to sign in
router.push("/");
return null;
}
@ -473,7 +477,14 @@ export default function PortfolioSettings({
<UsersPermissionsCard portfolioId={portfolioId} />
<div className="rounded-md border border-red-500 mt-2">
<Table>
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>
<TableHeader>
<TableRow>
<TableHead colSpan={2} className="text-lg text-brandblue">
Danger Zone:
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableHead className="text-brandblue">
@ -483,6 +494,7 @@ export default function PortfolioSettings({
assigned to this portfolio
</p>
</TableHead>
<TableCell className="flex justify-end">
<Button
className="bg-red-700 w-42"

View file

@ -123,47 +123,35 @@ function useCreateRemoteAssessment({
const { assetListFileKey, valuationDataFileKey } = useMemo(
() => generateS3Keys(userId, portfolioId),
[userId, portfolioId]
[userId, portfolioId],
);
const uploadMutation = useMutation({
mutationFn: uploadCsvToS3,
});
const uploadFileMutation = useMutation({
mutationFn: async ({
userId,
portfolioId,
fileKey,
csvFile,
}: {
userId: string;
portfolioId: string;
fileKey: string;
csvFile: Blob;
}) => {
// 1) Get presigned URL
const { url } = await generatePresignedUrl({
userId,
portfolioId,
fileKey,
});
const presignedMutation = useMutation({
mutationFn: generatePresignedUrl,
onSuccess: (data) => {
let csvFile: Blob;
if (data.fileKey === assetListFileKey) {
const assetList: {
uprn: number | null | undefined;
address: string;
postcode: string;
property_type?: string;
built_form?: string;
}[] = [
{
uprn,
address: addressLineOne,
postcode,
},
];
// 2) Upload to S3
await uploadCsvToS3({
presignedUrl: url,
file: csvFile,
});
// if we have property type and built form, include them. Handle typescript optionality
if (propertyType) {
assetList[0]["property_type"] = propertyType;
}
if (builtForm) {
assetList[0]["built_form"] = builtForm;
}
csvFile = new Blob([convertToCSV(assetList)], { type: "text/csv" });
} else {
const valuationData = [{ uprn, valuation }];
csvFile = new Blob([convertToCSV(valuationData)], { type: "text/csv" });
}
uploadMutation.mutate({ file: csvFile, presignedUrl: data.url });
return true;
},
});
@ -196,27 +184,51 @@ function useCreateRemoteAssessment({
}
async function handleSubmit(formData: RemoteAssessmentFormValues) {
// Build asset CSV
const assetList = [
{
uprn,
address: addressLineOne,
postcode,
...(propertyType && { property_type: propertyType }),
...(builtForm && { built_form: builtForm }),
},
];
const assetCsv = new Blob([convertToCSV(assetList)], {
type: "text/csv",
});
// Build valuation CSV
const valuationCsv = new Blob([convertToCSV([{ uprn, valuation }])], {
type: "text/csv",
});
// Upload asset list and valuation data and wait
await Promise.all([
presignedMutation.mutateAsync({
uploadFileMutation.mutateAsync({
userId,
portfolioId,
fileKey: assetListFileKey,
csvFile: assetCsv,
}),
presignedMutation.mutateAsync({
uploadFileMutation.mutateAsync({
userId,
portfolioId,
fileKey: valuationDataFileKey,
csvFile: valuationCsv,
}),
]);
// Now trigger engine (files guaranteed in S3)
await triggerEngine(formData);
}
return {
handleSubmit,
triggerEngine,
isUploading: uploadMutation.isLoading || presignedMutation.isLoading,
hasError: uploadMutation.isError || presignedMutation.isError,
isUploading: uploadFileMutation.isPending,
hasError: uploadFileMutation.isError,
};
}

View file

@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) {
export const config = {
matcher: [
// Protect only your apps authenticated areas
// Protect only apps authenticated areas
"/home/:path*",
"/portfolio/:path*",
"/search/:path*",