mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
save local work
This commit is contained in:
commit
054098c563
30 changed files with 30805 additions and 383 deletions
|
|
@ -0,0 +1,149 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
type MeasureAggregateRow = {
|
||||
measure_type: string | null;
|
||||
type: string | null;
|
||||
includes_battery: boolean | null;
|
||||
homes_count: number;
|
||||
total_cost: number | null;
|
||||
average_cost: number | null;
|
||||
};
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
|
||||
) {
|
||||
const { portfolioId, scenarioId } = await props.params;
|
||||
|
||||
const pid = BigInt(portfolioId);
|
||||
const sid = BigInt(scenarioId);
|
||||
|
||||
// TEMP: Remove batteries as underspecified
|
||||
// const result = await db.execute(sql`
|
||||
// WITH latest_plans AS (
|
||||
// SELECT DISTINCT ON (property_id)
|
||||
// *
|
||||
// FROM plan
|
||||
// WHERE portfolio_id = ${pid}
|
||||
// AND scenario_id = ${sid}
|
||||
// ORDER BY property_id, created_at DESC
|
||||
// ),
|
||||
|
||||
// recommendation_flags AS (
|
||||
// SELECT
|
||||
// r.id AS recommendation_id,
|
||||
// r.measure_type AS measure_type,
|
||||
// r.property_id AS property_id,
|
||||
// r.estimated_cost AS estimated_cost,
|
||||
// BOOL_OR(m.includes_battery) AS includes_battery
|
||||
|
||||
// FROM latest_plans lp
|
||||
// JOIN plan_recommendations pr
|
||||
// ON pr.plan_id = lp.id
|
||||
// JOIN recommendation r
|
||||
// ON r.id = pr.recommendation_id
|
||||
|
||||
// LEFT JOIN recommendation_materials rm
|
||||
// ON rm.recommendation_id = r.id
|
||||
// LEFT JOIN material m
|
||||
// ON m.id = rm.material_id
|
||||
// AND m.is_active = true
|
||||
|
||||
// WHERE r.default = true
|
||||
// AND r.already_installed = false
|
||||
|
||||
// GROUP BY
|
||||
// r.id,
|
||||
// r.measure_type,
|
||||
// r.property_id,
|
||||
// r.estimated_cost
|
||||
// )
|
||||
|
||||
// SELECT
|
||||
// measure_type,
|
||||
// COALESCE(includes_battery, false) AS includes_battery,
|
||||
|
||||
// COUNT(DISTINCT property_id)::int AS homes_count,
|
||||
// SUM(estimated_cost)::float AS total_cost,
|
||||
// AVG(estimated_cost)::float AS average_cost
|
||||
|
||||
// FROM recommendation_flags
|
||||
// GROUP BY
|
||||
// measure_type,
|
||||
// includes_battery
|
||||
// ORDER BY total_cost DESC;
|
||||
// `);
|
||||
|
||||
const result = await db.execute(sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
ORDER BY property_id, created_at DESC
|
||||
),
|
||||
|
||||
recommendation_flags AS (
|
||||
SELECT
|
||||
r.id AS recommendation_id,
|
||||
r.measure_type AS measure_type,
|
||||
r.property_id AS property_id,
|
||||
r.estimated_cost AS estimated_cost,
|
||||
r.type AS type
|
||||
|
||||
FROM latest_plans lp
|
||||
JOIN plan_recommendations pr
|
||||
ON pr.plan_id = lp.id
|
||||
JOIN recommendation r
|
||||
ON r.id = pr.recommendation_id
|
||||
|
||||
LEFT JOIN recommendation_materials rm
|
||||
ON rm.recommendation_id = r.id
|
||||
LEFT JOIN material m
|
||||
ON m.id = rm.material_id
|
||||
AND m.is_active = true
|
||||
|
||||
WHERE r.default = true
|
||||
AND r.already_installed = false
|
||||
|
||||
GROUP BY
|
||||
r.id,
|
||||
r.measure_type,
|
||||
r.type,
|
||||
r.property_id,
|
||||
r.estimated_cost
|
||||
)
|
||||
|
||||
SELECT
|
||||
measure_type,
|
||||
type,
|
||||
|
||||
COUNT(DISTINCT property_id)::int AS homes_count,
|
||||
SUM(estimated_cost)::float AS total_cost,
|
||||
AVG(estimated_cost)::float AS average_cost
|
||||
|
||||
FROM recommendation_flags
|
||||
GROUP BY
|
||||
measure_type,
|
||||
type
|
||||
ORDER BY total_cost DESC;
|
||||
`);
|
||||
|
||||
const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({
|
||||
measureType: row.measure_type ?? "unknown",
|
||||
type: row.type ?? "unknown",
|
||||
homesCount: row.homes_count,
|
||||
totalCost: Number(row.total_cost ?? 0),
|
||||
averageCost: Number(row.average_cost ?? 0),
|
||||
// includesBattery: row.includes_battery ?? false,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
portfolioId: Number(portfolioId),
|
||||
scenarioId: Number(scenarioId),
|
||||
measures,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
6
src/app/db/migrations/0135_lovely_spectrum.sql
Normal file
6
src/app/db/migrations/0135_lovely_spectrum.sql
Normal 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;
|
||||
1
src/app/db/migrations/0136_boring_charles_xavier.sql
Normal file
1
src/app/db/migrations/0136_boring_charles_xavier.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX "recommendation_property_id_idx" ON "recommendation" USING btree ("property_id");
|
||||
1
src/app/db/migrations/0137_shallow_speedball.sql
Normal file
1
src/app/db/migrations/0137_shallow_speedball.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX "recommendation_materials_recommendation_id_idx" ON "recommendation_materials" USING btree ("recommendation_id");
|
||||
1
src/app/db/migrations/0138_neat_havok.sql
Normal file
1
src/app/db/migrations/0138_neat_havok.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE UNIQUE INDEX "uq_property_portfolio_uprn" ON "property" USING btree ("portfolio_id","uprn") WHERE "property"."uprn" IS NOT NULL;
|
||||
1
src/app/db/migrations/0139_skinny_legion.sql
Normal file
1
src/app/db/migrations/0139_skinny_legion.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE UNIQUE INDEX "uq_epc_store_uprn" ON "epc_store" USING btree ("uprn");
|
||||
2
src/app/db/migrations/0140_keen_dreaming_celestial.sql
Normal file
2
src/app/db/migrations/0140_keen_dreaming_celestial.sql
Normal 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");
|
||||
4795
src/app/db/migrations/meta/0135_snapshot.json
Normal file
4795
src/app/db/migrations/meta/0135_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4811
src/app/db/migrations/meta/0136_snapshot.json
Normal file
4811
src/app/db/migrations/meta/0136_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4827
src/app/db/migrations/meta/0137_snapshot.json
Normal file
4827
src/app/db/migrations/meta/0137_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4850
src/app/db/migrations/meta/0138_snapshot.json
Normal file
4850
src/app/db/migrations/meta/0138_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4866
src/app/db/migrations/meta/0139_snapshot.json
Normal file
4866
src/app/db/migrations/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
4904
src/app/db/migrations/meta/0140_snapshot.json
Normal file
4904
src/app/db/migrations/meta/0140_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
199
src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
Normal file
199
src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ export interface BaselineMetrics {
|
|||
ageBands: AgeBandCount[];
|
||||
epcBands: EpcBandCount[];
|
||||
estimatedCounts: EstimatedCounts;
|
||||
expiredEpcs: number;
|
||||
likelyDowngrades: number;
|
||||
}
|
||||
|
||||
export type MetricKey =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue