save local work

This commit is contained in:
Jun-te Kim 2026-01-05 12:37:39 +00:00
commit 054098c563
30 changed files with 30805 additions and 383 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

@ -3,89 +3,123 @@ import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
type PlanRow = {
id: bigint;
post_sap_points: number | null;
post_co2_emissions: number | null;
post_energy_bill: number | null;
post_energy_consumption: number | null;
valuation_post_retrofit: number | null;
valuation_increase: number | null;
co2_savings: number | null;
energy_bill_savings: number | null;
energy_consumption_savings: number | null;
type BaselineAggregates = {
n_units: number;
avg_sap: number | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
sap_points_array: (number | null)[];
};
type UpgradedAggregates = {
n_units_upgraded: number;
total_cost: number | null;
contingency: number | null;
total_funding: number | null;
};
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
) {
console.log("In the request ");
const { portfolioId, scenarioId } = await props.params;
const pid = BigInt(portfolioId);
const sid = BigInt(scenarioId);
// Fetch all plans
const planRows = await db.execute(sql`
SELECT
id,
post_sap_points,
post_co2_emissions,
post_energy_bill,
post_energy_consumption,
valuation_post_retrofit,
valuation_increase,
co2_savings,
energy_bill_savings,
energy_consumption_savings
//
// ----------------------------------------------------------
// 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};
`);
AND scenario_id = ${sid}
ORDER BY property_id, created_at DESC
)
const plans = planRows.rows as PlanRow[];
SELECT
COUNT(*)::int AS n_units,
if (plans.length === 0) {
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,
ARRAY_AGG(post_sap_points) AS sap_points_array
FROM latest_plans;
`);
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 }
);
}
const planIds = plans.map((p) => p.id);
const {
n_units,
avg_sap,
avg_carbon,
avg_bills,
total_carbon,
total_bills,
sap_points_array,
} = baseline;
// Total funding
const planIdArray = sql`ARRAY[${sql.join(planIds, sql`, `)}]::bigint[]`;
const fundingRows = await db.execute(sql`
SELECT
SUM(COALESCE(project_funding, 0) + COALESCE(total_uplift, 0))::float AS total_funding
FROM funding_package
WHERE plan_id = ANY(${planIdArray});
`);
//
// ----------------------------------------------------------
// QUERY 2 — Upgrade metrics for properties receiving work
// ----------------------------------------------------------
//
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
)
// Averages + totals
const n = plans.length;
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
const avg_sap = plans.reduce((s, r) => s + (r.post_sap_points ?? 0), 0) / n;
const avg_carbon =
plans.reduce((s, r) => s + (r.post_co2_emissions ?? 0), 0) / n;
const avg_bills =
plans.reduce((s, r) => s + (r.post_energy_bill ?? 0), 0) / n;
FROM latest_plans lp
LEFT JOIN funding_package fp
ON fp.plan_id = lp.id
WHERE lp.cost_of_works > 0.01;
`);
const total_carbon = plans.reduce(
(s, r) => s + (r.post_co2_emissions ?? 0),
0
);
const total_bills = plans.reduce((s, r) => s + (r.post_energy_bill ?? 0), 0);
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
// Financial
const totalCost = 0;
const contingency = 0;
const funding = Number(fundingRows.rows[0]?.total_funding ?? 0);
const netCost = totalCost - funding;
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.28; // Placeholder for PC cost
// NEW — scenario EPC band counts
//
// ----------------------------------------------------------
// EPC band distribution (all properties)
// ----------------------------------------------------------
//
const scenario_epc_counts: Record<string, number> = {
A: 0,
B: 0,
@ -97,25 +131,35 @@ export async function GET(
Unknown: 0,
};
for (const p of plans) {
const band = sapToEpc(p.post_sap_points);
for (const sap of sap_points_array) {
const band = sapToEpc(sap);
scenario_epc_counts[band] += 1;
}
//
// ----------------------------------------------------------
// RESPONSE
// ----------------------------------------------------------
//
return NextResponse.json({
avg_sap: avg_sap.toFixed(1),
// Baseline metrics (all units)
avg_sap: avg_sap !== null ? Number(avg_sap).toFixed(1) : null,
avg_carbon,
avg_bills,
total_carbon,
total_bills,
total_cost: totalCost,
contingency,
total_funding: funding,
net_cost: netCost,
net_cost_per_unit: n > 0 ? netCost / n : 0,
n_units: n,
// NEW DATA for Overlay
n_units,
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,
});
}

View file

@ -38,10 +38,10 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
href: `/portfolio/${portfolioId}`,
},
{
label: "Retrofit Summary",
label: "Reporting",
icon: ChartBarIcon,
match: (p: string) => p.startsWith(`/portfolio/${portfolioId}/summary`),
href: `/portfolio/${portfolioId}/summary`,
match: (p: string) => p === `/portfolio/${portfolioId}/reporting`,
href: `/portfolio/${portfolioId}/reporting`,
},
{
label: "Decent Homes",

View file

@ -0,0 +1,6 @@
ALTER TABLE "property_details_epc" ADD COLUMN "lodgement_date" timestamp;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "is_expired" boolean;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "sap_05_score" real;--> statement-breakpoint
ALTER TABLE "property_details_epc" ADD COLUMN "sap_05_epc_rating" "epc";--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "cost_of_works" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "contingency_cost" real;

View file

@ -0,0 +1 @@
CREATE INDEX "recommendation_property_id_idx" ON "recommendation" USING btree ("property_id");

View file

@ -0,0 +1 @@
CREATE INDEX "recommendation_materials_recommendation_id_idx" ON "recommendation_materials" USING btree ("recommendation_id");

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX "uq_property_portfolio_uprn" ON "property" USING btree ("portfolio_id","uprn") WHERE "property"."uprn" IS NOT NULL;

View file

@ -0,0 +1 @@
CREATE UNIQUE INDEX "uq_epc_store_uprn" ON "epc_store" USING btree ("uprn");

View file

@ -0,0 +1,2 @@
CREATE UNIQUE INDEX "uq_property_details_epc_property_portfolio" ON "property_details_epc" USING btree ("property_id","portfolio_id");--> statement-breakpoint
CREATE UNIQUE INDEX "uq_property_details_spatial_uprn" ON "property_details_spatial" USING btree ("uprn");

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -946,6 +946,48 @@
"when": 1764886152458,
"tag": "0134_lying_lester",
"breakpoints": true
},
{
"idx": 135,
"version": "7",
"when": 1765214013546,
"tag": "0135_lovely_spectrum",
"breakpoints": true
},
{
"idx": 136,
"version": "7",
"when": 1765397663012,
"tag": "0136_boring_charles_xavier",
"breakpoints": true
},
{
"idx": 137,
"version": "7",
"when": 1765400667595,
"tag": "0137_shallow_speedball",
"breakpoints": true
},
{
"idx": 138,
"version": "7",
"when": 1766319125106,
"tag": "0138_neat_havok",
"breakpoints": true
},
{
"idx": 139,
"version": "7",
"when": 1766321608808,
"tag": "0139_skinny_legion",
"breakpoints": true
},
{
"idx": 140,
"version": "7",
"when": 1766389269465,
"tag": "0140_keen_dreaming_celestial",
"breakpoints": true
}
]
}

View file

@ -5,6 +5,7 @@ import {
jsonb,
timestamp,
bigint,
uniqueIndex,
} from "drizzle-orm/pg-core";
// This table stores postcode search results from the OS Places API
@ -23,22 +24,26 @@ export interface EpcApiResponse {
rows?: Record<string, any>;
}
export const epcStore = pgTable("epc_store", {
id: serial("id").primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }),
export const epcStore = pgTable(
"epc_store",
{
id: serial("id").primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }),
// Timestamp for when the EPC API entry was first stored
epcApiCreatedAt: timestamp("epc_api_created_at"),
// Timestamp for when the EPC API entry was first stored
epcApiCreatedAt: timestamp("epc_api_created_at"),
// EPC API response for the UPRN
epcApi: jsonb("epc_api").$type<EpcApiResponse>(),
// EPC API response for the UPRN
epcApi: jsonb("epc_api").$type<EpcApiResponse>(),
// Timestamp for when the EPC page was stored
epcPageCreatedAt: timestamp("epc_page_created_at"),
// Timestamp for when the EPC page was stored
epcPageCreatedAt: timestamp("epc_page_created_at"),
// HTML content of the EPC page
epcPage: text("epc_page"),
// HTML content of the EPC page
epcPage: text("epc_page"),
// RRN of the EPC page
epcPageRrn: text("epc_page_rrn"),
});
// RRN of the EPC page
epcPageRrn: text("epc_page_rrn"),
},
(table) => [uniqueIndex("uq_epc_store_uprn").on(table.uprn)]
);

View file

@ -9,10 +9,12 @@ import {
boolean,
smallint,
bigint,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { portfolio, PortfolioStatus } from "./portfolio";
import { InferModel } from "drizzle-orm";
import { materialTypeEnum } from "./materials";
import { sql } from "drizzle-orm";
// This is a placeholder for the property schema
export interface PropertyMeta {
@ -91,35 +93,52 @@ export const propertyCreationStatusEnum = pgEnum(
export const epcEnum = pgEnum("epc", Epc);
export const propertyStatusEnum = pgEnum("status", PortfolioStatus);
export const property = pgTable("property", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
creationStatus: propertyCreationStatusEnum("creation_status").notNull(),
uprn: bigint("uprn", { mode: "bigint" }),
landlordPropertyId: text("landlord_property_id"), // Optional ID used by landlords
buildingReferenceNumber: bigint("building_reference_number", {
mode: "bigint",
}),
status: propertyStatusEnum("status"),
address: text("address"),
postcode: text("postcode"),
hasPreConditionReport: boolean("has_pre_condition_report"),
hasRecommendations: boolean("has_recommendations"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
propertyType: text("property_type"),
builtForm: text("built_form"),
localAuthority: text("local_authority"),
constituency: text("constituency"),
numberOfRooms: integer("number_of_rooms"),
yearBuilt: text("year_built"),
tenure: text("tenure"),
currentEpcRating: epcEnum("current_epc_rating"),
currentSapPoints: real("current_sap_points"),
currentValuation: real("current_valuation"),
});
export const property = pgTable(
"property",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
creationStatus: propertyCreationStatusEnum("creation_status").notNull(),
uprn: bigint("uprn", { mode: "bigint" }),
landlordPropertyId: text("landlord_property_id"),
buildingReferenceNumber: bigint("building_reference_number", {
mode: "bigint",
}),
status: propertyStatusEnum("status"),
address: text("address"),
postcode: text("postcode"),
hasPreConditionReport: boolean("has_pre_condition_report"),
hasRecommendations: boolean("has_recommendations"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
propertyType: text("property_type"),
builtForm: text("built_form"),
localAuthority: text("local_authority"),
constituency: text("constituency"),
numberOfRooms: integer("number_of_rooms"),
yearBuilt: text("year_built"),
tenure: text("tenure"),
currentEpcRating: epcEnum("current_epc_rating"),
currentSapPoints: real("current_sap_points"),
currentValuation: real("current_valuation"),
},
(table) => [
uniqueIndex("uq_property_portfolio_uprn")
.on(table.portfolioId, table.uprn)
.where(sql`${table.uprn} IS NOT NULL`),
]
);
export const FeatureRating: [string, ...string[]] = [
"Very good",
@ -131,77 +150,96 @@ export const FeatureRating: [string, ...string[]] = [
export const FeatureRatingNumeric: [number, ...number[]] = [5, 4, 3, 2, 1];
export const propertyDetailsEpc = pgTable("property_details_epc", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
fullAddress: text("full_address"),
totalFloorArea: real("total_floor_area"),
walls: text("walls"),
wallsRating: smallint("walls_rating"),
roof: text("roof"),
roofRating: smallint("roof_rating"),
floor: text("floor"),
floorRating: smallint("floor_rating"),
windows: text("windows"),
windowsRating: smallint("windows_rating"),
heating: text("heating"),
heatingRating: smallint("heating_rating"),
heatingControls: text("heating_controls"),
heatingControlsRating: smallint("heating_controls_rating"),
hotWater: text("hot_water"),
hotWaterRating: smallint("hot_water_rating"),
lighting: text("lighting"),
lightingRating: smallint("lighting_rating"),
mainfuel: text("mainfuel"),
ventilation: text("ventilation"),
solarPv: real("solar_pv"),
solarHotWater: boolean("solar_hot_water"),
windTurbine: smallint("wind_turbine"),
floorHeight: real("floor_height"),
numberHeatedRooms: integer("number_heated_rooms"),
heatLossCorridor: boolean("heat_loss_corridor"),
unheatedCorridorLength: real("unheated_corridor_length"),
numberOpenFireplaces: integer("number_of_open_fireplaces"),
numberExtensions: integer("number_of_extensions"),
numberStoreys: integer("number_of_storeys"),
mainsGas: boolean("mains_gas"),
energyTariff: text("energy_tariff"),
primaryEnergyConsumption: real("primary_energy_consumption"),
co2Emissions: real("co2_emissions"),
currentEnergyDemand: real("current_energy_demand"),
currentEnergyDemandHeatingHotwater: real(
"current_energy_demand_heating_hotwater"
),
estimated: boolean("estimated").default(false),
// We indicate if the property has an overwritten SAP 05 EPC. I.e. there is a valid EPC, however it's a SAP 05
// EPC which isn't particularly useful. This value is defaulted to False
sap05Overwritten: boolean("sap_05_overwritten").default(false),
// Include current estimates for energy bills, across the different types of energy
// These predictions are based on the EPC predicted consumptions + current energy prices
heatingEnergyCostCurrent: real("heating_cost_current"),
hotWaterEnergyCostCurrent: real("hot_water_cost_current"),
lightingEnergyCostCurrent: real("lighting_cost_current"),
appliancesEnergyCostCurrent: real("appliances_cost_current"),
gasStandingCharge: real("gas_standing_charge"),
electricityStandingCharge: real("electricity_standing_charge"),
});
export const propertyDetailsEpc = pgTable(
"property_details_epc",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
fullAddress: text("full_address"),
// Date the EPC was lodged
lodgementDate: timestamp("lodgement_date"),
isExpired: boolean("is_expired"),
totalFloorArea: real("total_floor_area"),
walls: text("walls"),
wallsRating: smallint("walls_rating"),
roof: text("roof"),
roofRating: smallint("roof_rating"),
floor: text("floor"),
floorRating: smallint("floor_rating"),
windows: text("windows"),
windowsRating: smallint("windows_rating"),
heating: text("heating"),
heatingRating: smallint("heating_rating"),
heatingControls: text("heating_controls"),
heatingControlsRating: smallint("heating_controls_rating"),
hotWater: text("hot_water"),
hotWaterRating: smallint("hot_water_rating"),
lighting: text("lighting"),
lightingRating: smallint("lighting_rating"),
mainfuel: text("mainfuel"),
ventilation: text("ventilation"),
solarPv: real("solar_pv"),
solarHotWater: boolean("solar_hot_water"),
windTurbine: smallint("wind_turbine"),
floorHeight: real("floor_height"),
numberHeatedRooms: integer("number_heated_rooms"),
heatLossCorridor: boolean("heat_loss_corridor"),
unheatedCorridorLength: real("unheated_corridor_length"),
numberOpenFireplaces: integer("number_of_open_fireplaces"),
numberExtensions: integer("number_of_extensions"),
numberStoreys: integer("number_of_storeys"),
mainsGas: boolean("mains_gas"),
energyTariff: text("energy_tariff"),
primaryEnergyConsumption: real("primary_energy_consumption"),
co2Emissions: real("co2_emissions"),
currentEnergyDemand: real("current_energy_demand"),
currentEnergyDemandHeatingHotwater: real(
"current_energy_demand_heating_hotwater"
),
estimated: boolean("estimated").default(false),
// We indicate if the property has an overwritten SAP 05 EPC. I.e. there is a valid EPC, however it's a SAP 05
// EPC which isn't particularly useful. This value is defaulted to False
sap05Overwritten: boolean("sap_05_overwritten").default(false),
// When we've overwritten a SAP 05 EPC, we store the SAP 05 score and rating here for reference
sap05Score: real("sap_05_score"),
sap05EpcRating: epcEnum("sap_05_epc_rating"),
// Include current estimates for energy bills, across the different types of energy
// These predictions are based on the EPC predicted consumptions + current energy prices
heatingEnergyCostCurrent: real("heating_cost_current"),
hotWaterEnergyCostCurrent: real("hot_water_cost_current"),
lightingEnergyCostCurrent: real("lighting_cost_current"),
appliancesEnergyCostCurrent: real("appliances_cost_current"),
gasStandingCharge: real("gas_standing_charge"),
electricityStandingCharge: real("electricity_standing_charge"),
},
(table) => [
uniqueIndex("uq_property_details_epc_property_portfolio").on(
table.propertyId,
table.portfolioId
),
]
);
export const propertyDetailsSpatial = pgTable("property_details_spatial", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }),
xCoordinate: real("x_coordinate"),
yCoordinate: real("y_coordinate"),
latitude: real("latitude"),
longitude: real("longitude"),
conservationStatus: boolean("conservation_status"),
isListedBuilding: boolean("is_listed_building"),
isHeritageBuilding: boolean("is_heritage_building"),
});
export const propertyDetailsSpatial = pgTable(
"property_details_spatial",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
uprn: bigint("uprn", { mode: "bigint" }),
xCoordinate: real("x_coordinate"),
yCoordinate: real("y_coordinate"),
latitude: real("latitude"),
longitude: real("longitude"),
conservationStatus: boolean("conservation_status"),
isListedBuilding: boolean("is_listed_building"),
isHeritageBuilding: boolean("is_heritage_building"),
},
(table) => [uniqueIndex("uq_property_details_spatial_uprn").on(table.uprn)]
);
export const propertyDetailsMeter = pgTable("property_details_meter", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),

View file

@ -10,59 +10,72 @@ import {
bigint,
pgEnum,
integer,
index,
} from "drizzle-orm/pg-core";
import { Material, material } from "./materials";
import { InferModel } from "drizzle-orm";
import { z } from "zod";
export const recommendation = pgTable("recommendation", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
type: text("type").notNull(),
measureType: text("measure_type"),
description: text("description").notNull(),
estimatedCost: real("estimated_cost"),
constingencyCost: real("contingency_cost"),
// default will indicate whether a mtaterial is currently being used in a recommendation and we will use this boolean to switch
// between materials in the UI and switch off all materials entirely
default: boolean("default").notNull(),
startingUValue: real("starting_u_value"),
newUValue: real("new_u_value"),
sapPoints: real("sap_points"),
heatDemand: real("heat_demand"),
kwhSavings: real("kwh_savings"),
co2EquivalentSavings: real("co2_equivalent_savings"),
energySavings: real("energy_savings"),
energyCostSavings: real("energy_cost_savings"),
propertyValuationIncrease: real("property_valuation_increase"),
rentalYieldIncrease: real("rental_yield_increase"),
totalWorkHours: real("total_work_hours"),
labourDays: real("labour_days"),
alreadyInstalled: boolean("already_installed").default(false),
});
export const recommendation = pgTable(
"recommendation",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
type: text("type").notNull(),
measureType: text("measure_type"),
description: text("description").notNull(),
estimatedCost: real("estimated_cost"),
constingencyCost: real("contingency_cost"),
// default will indicate whether a mtaterial is currently being used in a recommendation and we will use this boolean to switch
// between materials in the UI and switch off all materials entirely
default: boolean("default").notNull(),
startingUValue: real("starting_u_value"),
newUValue: real("new_u_value"),
sapPoints: real("sap_points"),
heatDemand: real("heat_demand"),
kwhSavings: real("kwh_savings"),
co2EquivalentSavings: real("co2_equivalent_savings"),
energySavings: real("energy_savings"),
energyCostSavings: real("energy_cost_savings"),
propertyValuationIncrease: real("property_valuation_increase"),
rentalYieldIncrease: real("rental_yield_increase"),
totalWorkHours: real("total_work_hours"),
labourDays: real("labour_days"),
alreadyInstalled: boolean("already_installed").default(false),
},
(table) => [index("recommendation_property_id_idx").on(table.propertyId)]
);
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
export const unitQuantityEnum = pgEnum("unit_quantity", unitQuantity);
export const recommendationMaterials = pgTable("recommendation_materials", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
recommendationId: bigint("recommendation_id", {
mode: "bigint",
})
.notNull()
.references(() => recommendation.id),
materialId: bigint("material_id", { mode: "bigint" })
.notNull()
.references(() => material.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
depth: real("depth"),
quantity: real("quantity"),
quantityUnit: unitQuantityEnum("quantity_unit"),
estimatedCost: real("estimated_cost").notNull(),
});
export const recommendationMaterials = pgTable(
"recommendation_materials",
{
id: bigserial("id", { mode: "bigint" }).primaryKey(),
recommendationId: bigint("recommendation_id", {
mode: "bigint",
})
.notNull()
.references(() => recommendation.id),
materialId: bigint("material_id", { mode: "bigint" })
.notNull()
.references(() => material.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
depth: real("depth"),
quantity: real("quantity"),
quantityUnit: unitQuantityEnum("quantity_unit"),
estimatedCost: real("estimated_cost").notNull(),
},
(table) => [
index("recommendation_materials_recommendation_id_idx").on(
table.recommendationId
),
]
);
// We create a plan type, for common plan types that we produce for clients
export const PlanType: [string, ...string[]] = [
@ -99,9 +112,6 @@ export const plan = pgTable("plan", {
createdAt: timestamp("created_at").notNull().defaultNow(),
isDefault: boolean("is_default").notNull(),
totalCost: real("total_cost"),
contingency: real("contingency"),
// ─────────────────────────────────────────────────────────
// Valuation metrics (existing)
// ─────────────────────────────────────────────────────────
@ -139,6 +149,10 @@ export const plan = pgTable("plan", {
valuationPostRetrofit: real("valuation_post_retrofit"),
valuationIncrease: real("valuation_increase"),
// Plan costing data
costOfWorks: real("cost_of_works"),
contingencyCost: real("contingency_cost"),
// ─────────────────────────────────────────────────────────
// Plan type stays as-is
// ─────────────────────────────────────────────────────────

View file

@ -118,3 +118,107 @@
.animate-spin {
animation: spin 1s linear infinite;
}
@media print {
body {
background: white;
}
/* ---------------------------------
PAGE LAYOUT
--------------------------------- */
@page {
margin: 0;
}
/* Outer page padding (NOT scaled) */
.print-page {
padding: 20px 24px;
}
/* Inner content (scaled slightly) */
.print-root {
transform: scale(0.94);
transform-origin: top left;
width: 106.4%; /* 1 / 0.94 */
}
/* ---------------------------------
HEADER
--------------------------------- */
.print-header {
display: flex;
align-items: center;
gap: 16px;
border-bottom: 2px solid #0b3c5d;
padding-bottom: 12px;
margin-bottom: 20px;
}
/* ---------------------------------
PAGE BREAKS
--------------------------------- */
.page-break {
break-before: page;
page-break-before: always;
}
.avoid-break {
break-inside: avoid;
page-break-inside: avoid;
}
/* ---------------------------------
GRID STABILITY
--------------------------------- */
.print-grid-3 {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 14px !important;
}
.print-grid-2 {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 14px !important;
}
/* ---------------------------------
CARD FIXES
--------------------------------- */
section,
.card,
.gradient-card {
break-inside: avoid;
page-break-inside: avoid;
}
.gradient-card {
background: none !important;
border: 2px solid #e5e7eb;
}
.gradient-card > div {
padding: 10px !important;
}
/* Disable gradient text */
.print-text-solid {
background: none !important;
-webkit-background-clip: initial !important;
background-clip: initial !important;
color: #0b3c5d !important;
}
/* Hide UI chrome */
button,
nav,
.no-print {
display: none !important;
}
}

View file

@ -81,7 +81,12 @@ export default async function Page(props: {
return (
<>
<div className="flex justify-center">
<<<<<<< HEAD
<div className="col-span-8 bg-white">
=======
<div className="grid grid-cols-11 w-full max-w-8xl">
<div className="col-span-11 bg-white">
>>>>>>> f9ce0ea738308284245dd2fa2b5ed3f22d4700e9
{properties.length === 0 ? (
<EmptyPropertyState />
) : (

View file

@ -171,7 +171,7 @@ export function DashboardSummaryCards({
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"
: "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}

View file

@ -8,19 +8,29 @@ import {
CardFooter,
} from "@/app/shadcn_components/ui/card";
import { motion } from "framer-motion";
import { FileQuestion, BarChart3 } from "lucide-react";
import { FileQuestion, AlertTriangle, TrendingDown } from "lucide-react";
import type { EstimatedCounts } from "./types";
export function EpcQualityCards({
estimatedCounts,
total,
expiredEpcs,
likelyDowngrades,
}: {
estimatedCounts: EstimatedCounts;
total: number;
expiredEpcs: number;
likelyDowngrades: number;
}) {
// Missing EPCs (estimated = true)
const missing = estimatedCounts.estimated;
const pctMissing = total > 0 ? (missing / total) * 100 : 0;
const pctValid = 100 - pctMissing;
// Expired EPCs
const pctExpired = total > 0 ? (expiredEpcs / total) * 100 : 0;
// Likely downgrades
const pctDowngrades = total > 0 ? (likelyDowngrades / total) * 100 : 0;
const cards = [
{
@ -29,31 +39,45 @@ export function EpcQualityCards({
icon: FileQuestion,
color: "text-red-600",
value: missing,
subtitle: `${pctMissing.toFixed(1)}% missing EPCs (predicted only)`,
subtitle: `${pctMissing.toFixed(1)}% missing EPC records`,
barColor: "bg-red-500",
barWidth: pctMissing,
gradient: "bg-gradient-to-br from-white to-red-50/20",
},
{
key: "quality",
title: "EPC Data Coverage",
icon: BarChart3,
key: "expired",
title: "Expired EPCs",
icon: AlertTriangle,
color: "text-amber-600",
value: expiredEpcs,
subtitle: `${pctExpired.toFixed(1)}% of homes have expired EPCs`,
barColor: "bg-amber-500",
barWidth: pctExpired,
gradient: "bg-gradient-to-br from-white to-amber-50/20",
},
{
key: "downgrades",
title: "Likely EPC Downgrades",
icon: TrendingDown,
color: "text-brandblue",
value: `${pctValid.toFixed(1)}%`,
subtitle: "Percentage of homes with a valid EPC.",
value: likelyDowngrades,
subtitle: `${pctDowngrades.toFixed(1)}% likely EPC score reductions`,
barColor: "bg-brandblue",
barWidth: pctValid,
barWidth: pctDowngrades,
gradient: "bg-gradient-to-br from-white to-blue-50/20",
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<Card
key={c.key}
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-red-50/20 hover:shadow-md hover:-translate-y-0.5 transition-all"
className={`relative h-full flex flex-col border border-gray-100 ${c.gradient} hover:shadow-md hover:-translate-y-0.5 transition-all`}
>
{/* Header */}
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<div className="p-1.5 rounded-md bg-gray-100">
<motion.div whileHover={{ scale: 1.1 }} className="p-1">
@ -65,12 +89,13 @@ export function EpcQualityCards({
</CardTitle>
</CardHeader>
{/* Content */}
<CardContent className="flex flex-col pb-2">
<div className="text-2xl font-semibold text-brandblue">
{c.value}
</div>
{/* Correct mini bar per card */}
{/* Mini bar */}
<div className="w-full mt-3 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${c.barColor}`}
@ -79,6 +104,7 @@ export function EpcQualityCards({
</div>
</CardContent>
{/* Footer */}
<CardFooter className="pt-0 pb-4">
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardFooter>

View file

@ -7,6 +7,7 @@ import { DashboardSummaryCards } from "./DashboardSummaryCards";
import { BreakdownChart } from "./BreakdownChart";
import { EpcQualityCards } from "./EpcQualityCards";
import { ScenarioFinancialDrawer } from "./ScenarioFinancialDrawer";
import { ScenarioMeasuresModal } from "./ScenarioMeasuresModal";
import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider";
import {
@ -48,6 +49,24 @@ async function fetchScenarioReport({
return res.json();
}
async function fetchScenarioMeasures({
portfolioId,
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`
);
if (!res.ok) {
throw new Error("Failed to load measures");
}
return res.json();
}
export function ReportingClientArea({
baseline,
propertyTypes,
@ -57,6 +76,7 @@ export function ReportingClientArea({
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
null
);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const drawerOpen = Boolean(selectedScenarioId);
@ -77,9 +97,26 @@ export function ReportingClientArea({
enabled: !!selectedScenarioId, // only run when scenario selected
});
const {
data: measuresData,
isLoading: measuresLoading,
isError: measuresError,
} = useQuery({
queryKey: ["scenario-measures", portfolioId, selectedScenarioId],
queryFn: () =>
fetchScenarioMeasures({
portfolioId,
scenarioId: selectedScenarioId!,
}),
enabled: measuresOpen && !!selectedScenarioId,
});
const scenarioLoading = isLoading && !!selectedScenarioId;
// ----------------------------------------
// Build overlay for Dashboard Summary cards
// ----------------------------------------
const scenarioOverlay = scenarioData
? {
avgSap: {
@ -105,24 +142,37 @@ export function ReportingClientArea({
: null;
// ----------------------------------------
// Financial drawer values (from API)
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
// ----------------------------------------
const scenarioFinancial = scenarioData
const scenarioSpecific = scenarioData
? {
totalCost: scenarioData.total_cost,
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_cost > 0
? scenarioData.total_cost / scenarioData.avg_sap
scenarioData.construction_cost > 0
? scenarioData.gross_per_unit /
(scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0))
: 0,
costPerCo2:
scenarioData.total_cost > 0
? scenarioData.total_cost / scenarioData.total_carbon
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
scenarioData.total_carbon
: 0,
netCost: scenarioData.net_cost,
netCostPerUnit: scenarioData.net_cost_per_unit,
nUnits: scenarioData.n_units,
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;
@ -131,12 +181,55 @@ export function ReportingClientArea({
return (
<>
<ScenarioSelectorWrapper
scenarios={scenarios}
portfolioId={portfolioId}
selectedScenarioId={selectedScenarioId}
setSelectedScenarioId={setSelectedScenarioId}
/>
<div className="flex items-center justify-between gap-4">
{/* LEFT: Scenario selector */}
<ScenarioSelectorWrapper
scenarios={scenarios}
portfolioId={portfolioId}
selectedScenarioId={selectedScenarioId}
setSelectedScenarioId={setSelectedScenarioId}
/>
{/* RIGHT: Actions (only when scenario selected) */}
{selectedScenarioId && (
<div className="flex items-center gap-2">
<button
onClick={() => setMeasuresOpen(true)}
disabled={scenarioLoading}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
scenarioLoading
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
{scenarioLoading ? "Loading…" : "Show measures"}
</button>
<button
onClick={() => {
window.open(
`/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`,
"_blank"
);
}}
disabled={scenarioLoading}
className={`
rounded-md border px-3 py-2 text-sm font-medium transition
${
scenarioLoading
? "border-gray-200 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}
`}
>
Download PDF
</button>
</div>
)}
</div>
{/* LOADING + ERROR STATES */}
{isLoading && selectedScenarioId && (
@ -154,7 +247,7 @@ export function ReportingClientArea({
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioFinancial} />
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
<DashboardSummaryCards
@ -176,6 +269,8 @@ export function ReportingClientArea({
<EpcQualityCards
estimatedCounts={activeMetrics.estimatedCounts}
total={activeMetrics.total}
expiredEpcs={activeMetrics.expiredEpcs}
likelyDowngrades={activeMetrics.likelyDowngrades}
/>
</div>
</div>
@ -193,6 +288,14 @@ export function ReportingClientArea({
subtitle="Total bills, cost exposure, and potential funding pathways."
/>
<PlaceholderMetricCards items={FINANCIAL_PLACEHOLDERS} />
<ScenarioMeasuresModal
isOpen={measuresOpen}
onClose={() => setMeasuresOpen(false)}
isLoading={measuresLoading}
data={measuresData ?? null}
error={measuresError}
/>
</>
);
}

View file

@ -2,146 +2,338 @@
import { motion, AnimatePresence } from "framer-motion";
import { formatNumber } from "@/app/utils";
import clsx from "clsx";
// Premium Icons
/* Heroicons (outline) */
import {
Banknote,
ShieldAlert,
PiggyBank,
Scale,
Gauge,
Factory,
Home,
Users,
} from "lucide-react";
ArrowTrendingUpIcon,
ClipboardDocumentCheckIcon,
ScaleIcon,
HomeIcon,
BoltIcon,
FireIcon,
ChartBarIcon,
WrenchIcon,
} from "@heroicons/react/24/outline";
export function ScenarioFinancialDrawer({
open,
metrics,
}: {
/* Lucide */
import { Gauge } from "lucide-react";
/* ───────────────────────────────────────────── */
/* Types */
/* ───────────────────────────────────────────── */
interface ScenarioFinancialDrawerProps {
open: boolean;
metrics: any | null;
}
/* ───────────────────────────────────────────── */
/* Gradient Tokens */
/* ───────────────────────────────────────────── */
const gradients = {
green: "bg-gradient-to-r from-green-700 via-green-400 to-green-700",
blue: "bg-gradient-to-r from-brandblue via-sky-400 to-brandblue",
purple: "bg-gradient-to-r from-purple-700 via-purple-400 to-purple-700",
};
/* ───────────────────────────────────────────── */
/* Gradient Card Shell */
/* ───────────────────────────────────────────── */
function GradientCard({
gradient,
variant,
children,
}: {
gradient: string;
variant: "green" | "blue" | "purple";
children: React.ReactNode;
}) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
<motion.div
key="drawer"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6">
<h3 className="text-lg font-semibold text-brandblue mb-4">
Scenario Financial Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<Metric
label="Total Cost"
value={`£${formatNumber(metrics.totalCost)}`}
icon={Banknote}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
icon={ShieldAlert}
color="text-amber-600"
bg="bg-amber-200/40"
/>
<Metric
label="Funding"
value={`£${formatNumber(metrics.funding)}`}
icon={PiggyBank}
color="text-emerald-600"
bg="bg-emerald-200/40"
/>
<Metric
label="Net Cost"
value={`£${formatNumber(metrics.netCost)}`}
icon={Scale}
color="text-red-600"
bg="bg-red-200/40"
/>
<Metric
label="Cost per SAP point"
value={`£${formatNumber(metrics.costPerSap)}`}
icon={Gauge}
color="text-purple-600"
bg="bg-purple-200/40"
/>
<Metric
label="Cost per tonne CO₂"
value={`£${formatNumber(metrics.costPerCo2)}`}
icon={Factory}
color="text-slate-700"
bg="bg-slate-200/40"
/>
<Metric
label="Net Cost per Unit"
value={`£${formatNumber(metrics.netCostPerUnit)}`}
icon={Home}
color="text-sky-600"
bg="bg-sky-200/40"
/>
<Metric
label="Units Upgraded"
value={metrics.nUnits}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
</div>
</div>
</motion.div>
<div
className={clsx(
"relative rounded-lg p-[2px] gradient-card",
gradient,
`gradient-${variant}`
)}
</AnimatePresence>
>
<div className="rounded-[7px] bg-white h-full">{children}</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Single Metric Card */
/* ───────────────────────────────────────────── */
function Metric({
label,
value,
icon: Icon,
color,
bg,
gradient,
variant = "green",
}: {
label: string;
value: string | number;
icon: any;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
color: string;
bg: string;
gradient: string;
variant?: "green" | "blue" | "purple";
}) {
return (
<div
className="group flex flex-col rounded-lg border border-gray-200
bg-gradient-to-br from-white to-gray-50
p-3 shadow-sm hover:shadow-md
hover:border-brandblue/30 transition-all"
>
<div className="flex items-center gap-2 mb-1">
{/* coloured icon background */}
<div
className={`p-1.5 rounded-md ${bg} group-hover:opacity-80 transition`}
>
<Icon className={`h-4 w-4 ${color}`} />
</div>
<span className="text-[10px] uppercase tracking-wide font-semibold text-gray-500">
<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>
</div>
</GradientCard>
);
}
<span
className="text-lg md:text-xl font-semibold text-gray-900
group-hover:text-brandblue transition truncate"
>
{value}
</span>
/* ───────────────────────────────────────────── */
/* Paired Metric Card (Reusable Everywhere) */
/* ───────────────────────────────────────────── */
function PairedMetric({
title,
icon: Icon,
primary,
secondary,
gradient,
iconClassName = "text-gray-700",
variant = "green",
}: {
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
primary: { label: string; value: string };
secondary: { label: string; value: string };
gradient: string;
iconClassName?: string;
variant?: "green" | "blue" | "purple";
}) {
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>
</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>
);
}
/* ───────────────────────────────────────────── */
/* Section Header */
/* ───────────────────────────────────────────── */
function Section({
title,
subtitle,
icon: Icon,
gradient,
accentColor,
children,
}: {
title: string;
subtitle: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
gradient: string;
accentColor: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className={clsx("w-1 rounded-full self-stretch", gradient)} />
<div
className={clsx(
"rounded-lg p-2 bg-white shadow-sm border",
accentColor
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="pt-0.5">
<h4 className="text-base font-semibold text-gray-900">{title}</h4>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 print-grid-3">
{children}
</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Main Drawer */
/* ───────────────────────────────────────────── */
export function ScenarioFinancialDrawer({
open,
metrics,
}: ScenarioFinancialDrawerProps) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6 space-y-6">
<h3 className="text-lg font-semibold text-brandblue">
Scenario Impact Summary
</h3>
{/* BENEFITS */}
<Section
title="Benefits"
subtitle="Impact for occupants and the environment"
icon={ArrowTrendingUpIcon}
gradient={gradients.green}
accentColor="border-green-200 text-green-700"
>
<PairedMetric
title="Carbon impact"
icon={BoltIcon}
iconClassName="text-green-700"
primary={{
label: "Total carbon saved (t/yr)",
value: formatNumber(metrics.totalCarbonSaved),
}}
secondary={{
label: "Average per unit (t/yr)",
value: formatNumber(metrics.averageCaribonSaved),
}}
gradient={gradients.green}
variant="green"
/>
<PairedMetric
title="Bill savings"
icon={FireIcon}
iconClassName="text-green-700"
primary={{
label: "Total bill savings (£/yr)",
value: `£${formatNumber(metrics.totalBillsSaved)}`,
}}
secondary={{
label: "Average per unit (£/yr)",
value: `£${formatNumber(metrics.averageBillsSaved)}`,
}}
gradient={gradients.green}
variant="green"
/>
<Metric
label="Homes upgraded"
value={metrics.nUnits}
icon={HomeIcon}
color="text-green-700"
gradient={gradients.green}
variant="green"
/>
</Section>
{/* COSTS */}
<Section
title="Costs"
subtitle="Investment required to deliver the works"
icon={ClipboardDocumentCheckIcon}
gradient={gradients.blue}
accentColor="border-brandblue text-brandblue"
>
<PairedMetric
title="Delivery costs"
icon={WrenchIcon}
iconClassName="text-blue-600"
primary={{
label: "Construction works",
value: `£${formatNumber(metrics.constructionCost)}`,
}}
secondary={{
label: "Project delivery",
value: `£${formatNumber(metrics.pcCost)}`,
}}
gradient={gradients.blue}
variant="blue"
/>
<Metric
label="Gross cost per unit"
value={`£${formatNumber(metrics.grossPerUnit)}`}
icon={HomeIcon}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
icon={Gauge}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
/>
</Section>
{/* COST EFFECTIVENESS */}
<Section
title="Cost effectiveness"
subtitle="Value for money of the investment"
icon={ScaleIcon}
gradient={gradients.purple}
accentColor="border-purple-200 text-purple-700"
>
<PairedMetric
title="Efficiency metrics"
icon={ChartBarIcon}
iconClassName="text-purple-700"
primary={{
label: "£ per SAP point",
value: `£${formatNumber(metrics.costPerSap)}`,
}}
secondary={{
label: "£ per tonne CO₂",
value: `£${formatNumber(metrics.costPerCo2)}`,
}}
gradient={gradients.purple}
variant="purple"
/>
</Section>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

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

View file

@ -153,18 +153,64 @@ export async function getCountByPropertyType(
return result.rows;
}
export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
const result = await db.execute<{ expired: number }>(sql`
SELECT
SUM(
CASE
WHEN is_expired = true AND estimated = false
THEN 1
ELSE 0
END
)::int AS expired
FROM property_details_epc
WHERE portfolio_id = ${portfolioId};
`);
return result.rows[0].expired;
}
export async function getLikelyDowngrades(
portfolioId: number
): Promise<number> {
const result = await db.execute<{ downgrades: number }>(sql`
SELECT
COUNT(*)::int AS downgrades
FROM property p
JOIN property_details_epc e
ON e.property_id = p.id
WHERE p.portfolio_id = ${portfolioId}
AND e.sap_05_overwritten = true
AND p.current_sap_points IS NOT NULL
AND e.sap_05_score IS NOT NULL
AND p.current_sap_points < e.sap_05_score;
`);
return result.rows[0].downgrades;
}
export async function loadBaselineMetrics(
portfolioId: number
): Promise<BaselineMetrics> {
const [total, averages, totals, ageBands, epcBands, estimatedCounts] =
await Promise.all([
getPortfolioCounts(portfolioId),
getAverages(portfolioId),
getTotals(portfolioId),
getCountByAgeBand(portfolioId),
getCountByEpcBand(portfolioId),
getEstimatedCounts(portfolioId),
]);
const [
total,
averages,
totals,
ageBands,
epcBands,
estimatedCounts,
expiredEpcs,
likelyDowngrades,
] = await Promise.all([
getPortfolioCounts(portfolioId),
getAverages(portfolioId),
getTotals(portfolioId),
getCountByAgeBand(portfolioId),
getCountByEpcBand(portfolioId),
getEstimatedCounts(portfolioId),
getExpiredEpcCount(portfolioId),
getLikelyDowngrades(portfolioId),
]);
return {
total,
@ -173,6 +219,8 @@ export async function loadBaselineMetrics(
ageBands,
epcBands,
estimatedCounts,
expiredEpcs,
likelyDowngrades,
};
}

View file

@ -0,0 +1,66 @@
"use client";
import { useEffect } from "react";
export function AutoPrint() {
useEffect(() => {
let cancelled = false;
function waitForImages() {
const images = Array.from(document.images);
return Promise.all(
images.map((img) => {
if (img.complete) return Promise.resolve();
return new Promise<void>((resolve) => {
img.onload = () => resolve();
img.onerror = () => resolve(); // never block print
});
})
);
}
function waitForFontsSafe() {
try {
// Some browsers expose document.fonts but break on .ready
if (
"fonts" in document &&
document.fonts &&
typeof document.fonts.ready?.then === "function"
) {
return document.fonts.ready;
}
} catch {
// Ignore font readiness completely if browser misbehaves
}
return Promise.resolve();
}
async function printWhenReady() {
await Promise.all([waitForImages(), waitForFontsSafe()]);
if (cancelled) return;
// Ensure layout is flushed
requestAnimationFrame(() => {
// Close tab AFTER print dialog completes
window.onafterprint = () => {
window.close();
};
window.print();
});
}
printWhenReady();
return () => {
cancelled = true;
window.onafterprint = null;
};
}, []);
return null;
}

View file

@ -0,0 +1,199 @@
import { SectionDivider } from "../SectionDivider";
import { ScenarioFinancialDrawer } from "../ScenarioFinancialDrawer";
import { DashboardSummaryCards } from "../DashboardSummaryCards";
import { EpcQualityCards } from "../EpcQualityCards";
import { loadBaselineMetrics } from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
import type { BaselineMetrics } from "../types";
import Image from "next/image";
import { AutoPrint } from "./AutoPrint";
/* ---------------------------------------------
Base URL helper (Vercel + local)
--------------------------------------------- */
function getBaseUrl() {
if (process.env.VERCEL_BRANCH_URL) {
return `https://${process.env.VERCEL_BRANCH_URL}`;
}
if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`;
}
return "http://localhost:3000";
}
/* ---------------------------------------------
Server-side fetch for scenario metrics
--------------------------------------------- */
async function fetchScenarioReport({
portfolioId,
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
}) {
const res = await fetch(
`${getBaseUrl()}/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`,
{ cache: "no-store" }
);
if (!res.ok) {
throw new Error("Failed to load scenario report");
}
return res.json();
}
/* ---------------------------------------------
Page
--------------------------------------------- */
export default async function ReportingPdfPage(props: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ scenarioId?: string }>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
const scenarioId = Number(searchParams.scenarioId);
if (!scenarioId) {
return <div>No scenario selected</div>;
}
const portfolioId = Number(params.slug);
/* ---------------------------------------------
Fetch baseline + scenario (parallel)
--------------------------------------------- */
const [baseline, scenarioData]: [BaselineMetrics, any] = await Promise.all([
loadBaselineMetrics(portfolioId),
fetchScenarioReport({ portfolioId, scenarioId }),
]);
/* ---------------------------------------------
Scenario-only metrics for drawer
--------------------------------------------- */
const scenarioOverlay = scenarioData
? {
avgSap: {
baseline: baseline.averages.avg_sap ?? 0,
scenario: Number(scenarioData.avg_sap),
},
avgCarbon: {
baseline: Number(baseline.averages.avg_carbon ?? 0),
scenario: Number(scenarioData.avg_carbon),
baselineTotal: Number(baseline.totals.total_carbon ?? 0),
scenarioTotal: Number(scenarioData.total_carbon ?? 0),
},
avgBills: {
baseline: baseline.averages.avg_bills ?? 0,
scenario: scenarioData.avg_bills,
baselineTotal: baseline.totals.total_bills ?? 0,
scenarioTotal: scenarioData.total_bills,
},
valuation: { baseline: null, scenario: null },
scenarioEpcBands: scenarioData.scenario_epc_counts,
}
: null;
const scenarioSpecific = {
constructionCost: scenarioData.construction_cost,
pcCost: scenarioData.pc_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.construction_cost > 0
? scenarioData.gross_per_unit /
(scenarioData.avg_sap - (baseline.averages.avg_sap ?? 0))
: 0,
costPerCo2:
scenarioData.construction_cost > 0
? (scenarioData.construction_cost + scenarioData.pc_cost) /
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,
};
return (
<div className="print-page">
<div className="print-root space-y-4">
<AutoPrint />
{/* ------------------------------------------------
Branded header
------------------------------------------------ */}
<header className="flex items-center gap-3 border-b pb-2">
<Image
src="/domna_logo_blue_transparent_background.png"
alt="Domna Logo"
width={140}
height={40}
/>
<div>
<h1 className="text-xl font-semibold">Retrofit Scenario Report</h1>
</div>
</header>
{/* ------------------------------------------------
Scenario Impact Summary
------------------------------------------------ */}
<SectionDivider
title="Scenario Impact Summary"
subtitle="Financial, carbon and energy outcomes"
/>
<ScenarioFinancialDrawer open={true} metrics={scenarioSpecific} />
{/* ------------------------------------------------
Portfolio Summary (baseline)
------------------------------------------------ */}
<div className="page-break" />
<SectionDivider
title="Portfolio Summary"
subtitle="Headline performance indicators"
/>
<DashboardSummaryCards
total={baseline.total}
totals={baseline.totals}
averages={baseline.averages}
estimatedCounts={baseline.estimatedCounts}
scenarioOverlay={scenarioOverlay}
/>
{/* ------------------------------------------------
EPC Quality (baseline)
------------------------------------------------ */}
<SectionDivider
title="EPC Quality"
subtitle="Condition, compliance and performance"
/>
<EpcQualityCards
estimatedCounts={baseline.estimatedCounts}
total={baseline.total}
expiredEpcs={baseline.expiredEpcs}
likelyDowngrades={baseline.likelyDowngrades}
/>
</div>
</div>
);
}

View file

@ -40,6 +40,8 @@ export interface BaselineMetrics {
ageBands: AgeBandCount[];
epcBands: EpcBandCount[];
estimatedCounts: EstimatedCounts;
expiredEpcs: number;
likelyDowngrades: number;
}
export type MetricKey =