mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #174 from Hestia-Homes/feature/filter-bad-plans
Feature/filter bad plans
This commit is contained in:
commit
389b40ead1
9 changed files with 803 additions and 316 deletions
|
|
@ -2,8 +2,13 @@ import { db } from "@/app/db/db";
|
|||
import { sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
|
||||
|
||||
type BaselineAggregates = {
|
||||
/* =======================
|
||||
Types
|
||||
======================= */
|
||||
|
||||
type ScenarioAggregates = {
|
||||
n_units: number;
|
||||
avg_sap: number | null;
|
||||
avg_carbon: number | null;
|
||||
|
|
@ -11,7 +16,6 @@ type BaselineAggregates = {
|
|||
total_carbon: number | null;
|
||||
total_bills: number | null;
|
||||
total_sap_uplift: number | null;
|
||||
sap_points_array: (number | null)[];
|
||||
};
|
||||
|
||||
type UpgradedAggregates = {
|
||||
|
|
@ -21,120 +25,243 @@ type UpgradedAggregates = {
|
|||
total_funding: number | null;
|
||||
};
|
||||
|
||||
type PortfolioAggregates = {
|
||||
avg_sap: number | null;
|
||||
avg_carbon: number | null;
|
||||
avg_bills: number | null;
|
||||
total_carbon: number | null;
|
||||
total_bills: number | null;
|
||||
};
|
||||
|
||||
type EpcRow = {
|
||||
effective_sap: number | null;
|
||||
};
|
||||
|
||||
/* =======================
|
||||
Constants
|
||||
======================= */
|
||||
|
||||
const EPC_MIN_SAP: Record<string, number> = {
|
||||
A: 92,
|
||||
B: 81,
|
||||
C: 69,
|
||||
D: 55,
|
||||
E: 39,
|
||||
F: 21,
|
||||
G: 0,
|
||||
};
|
||||
|
||||
/* =======================
|
||||
Route
|
||||
======================= */
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
|
||||
props: { params: Promise<{ portfolioId: string; scenarioId: string }> },
|
||||
) {
|
||||
const { portfolioId, scenarioId } = await props.params;
|
||||
const pid = BigInt(portfolioId);
|
||||
const sid = BigInt(scenarioId);
|
||||
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
// QUERY 1 — Baseline metrics for *all* properties
|
||||
// ----------------------------------------------------------
|
||||
//
|
||||
const baselineResult = await db.execute(sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
ORDER BY property_id, created_at DESC
|
||||
)
|
||||
|
||||
SELECT
|
||||
COUNT(*)::int AS n_units,
|
||||
|
||||
AVG(lp.post_sap_points)::float AS avg_sap,
|
||||
AVG(lp.post_co2_emissions)::float AS avg_carbon,
|
||||
AVG(lp.post_energy_bill)::float AS avg_bills,
|
||||
|
||||
SUM(lp.post_co2_emissions)::float AS total_carbon,
|
||||
SUM(lp.post_energy_bill)::float AS total_bills,
|
||||
|
||||
SUM(
|
||||
CASE
|
||||
WHEN lp.cost_of_works > 0.01
|
||||
AND p.current_sap_points IS NOT NULL
|
||||
AND lp.post_sap_points IS NOT NULL
|
||||
THEN lp.post_sap_points - p.current_sap_points
|
||||
ELSE 0
|
||||
END
|
||||
)::float AS total_sap_uplift,
|
||||
|
||||
ARRAY_AGG(lp.post_sap_points) AS sap_points_array
|
||||
|
||||
FROM latest_plans lp
|
||||
JOIN property p
|
||||
ON p.id = lp.property_id;
|
||||
`);
|
||||
|
||||
const baseline = baselineResult.rows[0] as BaselineAggregates | undefined;
|
||||
|
||||
if (!baseline || baseline.n_units === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No plans found for this scenario" },
|
||||
{ status: 404 }
|
||||
);
|
||||
if (!scenarioId || scenarioId === "null") {
|
||||
return NextResponse.json({ error: "Invalid scenarioId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const {
|
||||
n_units,
|
||||
avg_sap,
|
||||
avg_carbon,
|
||||
avg_bills,
|
||||
total_carbon,
|
||||
total_bills,
|
||||
sap_points_array,
|
||||
} = baseline;
|
||||
const pid = BigInt(portfolioId);
|
||||
const sid = BigInt(scenarioId);
|
||||
const hideNonCompliant =
|
||||
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
|
||||
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
// QUERY 2 — Upgrade metrics for properties receiving work
|
||||
// ----------------------------------------------------------
|
||||
//
|
||||
/* ----------------------------------------------------------
|
||||
Query 0 — scenario definition
|
||||
---------------------------------------------------------- */
|
||||
const scenarioResult = await db.execute(sql`
|
||||
SELECT goal, goal_value
|
||||
FROM scenario
|
||||
WHERE id = ${sid}
|
||||
AND portfolio_id = ${pid}
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
const scenario = scenarioResult.rows[0] as
|
||||
| { goal: PortfolioGoalType; goal_value: string }
|
||||
| undefined;
|
||||
|
||||
if (!scenario) {
|
||||
return NextResponse.json({ error: "Scenario not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const minSap =
|
||||
scenario.goal === "Increasing EPC"
|
||||
? EPC_MIN_SAP[scenario.goal_value]
|
||||
: null;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 1 — Scenario metrics (PLANS ONLY)
|
||||
---------------------------------------------------------- */
|
||||
const scenarioMetricsResult = await db.execute(sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
AND (
|
||||
${hideNonCompliant} = false
|
||||
OR (
|
||||
${minSap}::float IS NOT NULL
|
||||
AND post_sap_points >= ${minSap}::float
|
||||
)
|
||||
)
|
||||
ORDER BY property_id, created_at DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::int AS n_units,
|
||||
AVG(post_sap_points)::float AS avg_sap,
|
||||
AVG(post_co2_emissions)::float AS avg_carbon,
|
||||
AVG(post_energy_bill)::float AS avg_bills,
|
||||
SUM(post_co2_emissions)::float AS total_carbon,
|
||||
SUM(post_energy_bill)::float AS total_bills,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN cost_of_works > 0
|
||||
AND post_sap_points IS NOT NULL
|
||||
THEN post_sap_points - p.current_sap_points
|
||||
ELSE 0
|
||||
END
|
||||
)::float AS total_sap_uplift
|
||||
FROM latest_plans lp
|
||||
JOIN property p ON p.id = lp.property_id;
|
||||
`);
|
||||
|
||||
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 1b — Upgrade costs (PLANS ONLY)
|
||||
---------------------------------------------------------- */
|
||||
const upgradedResult = await db.execute(sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
ORDER BY property_id, created_at DESC
|
||||
)
|
||||
|
||||
SELECT
|
||||
COUNT(*)::int AS n_units_upgraded,
|
||||
SUM(cost_of_works)::float AS total_cost,
|
||||
SUM(contingency_cost)::float AS contingency,
|
||||
SUM(
|
||||
COALESCE(fp.project_funding, 0)
|
||||
+ COALESCE(fp.total_uplift, 0)
|
||||
)::float AS total_funding
|
||||
|
||||
FROM latest_plans lp
|
||||
LEFT JOIN funding_package fp
|
||||
ON fp.plan_id = lp.id
|
||||
WHERE lp.cost_of_works > 0.01;
|
||||
`);
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid}
|
||||
AND (
|
||||
${hideNonCompliant} = false
|
||||
OR (
|
||||
${minSap}::float IS NOT NULL
|
||||
AND post_sap_points >= ${minSap}::float
|
||||
)
|
||||
)
|
||||
ORDER BY property_id, created_at DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::int AS n_units_upgraded,
|
||||
SUM(cost_of_works)::float AS total_cost,
|
||||
SUM(contingency_cost)::float AS contingency,
|
||||
SUM(
|
||||
COALESCE(fp.project_funding, 0) +
|
||||
COALESCE(fp.total_uplift, 0)
|
||||
)::float AS total_funding
|
||||
FROM latest_plans lp
|
||||
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
|
||||
WHERE lp.cost_of_works > 0;
|
||||
`);
|
||||
|
||||
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
|
||||
|
||||
const n_units_upgraded = upgraded.n_units_upgraded;
|
||||
const construction_cost = upgraded.total_cost ?? 0;
|
||||
const contingency = upgraded.contingency ?? 0;
|
||||
const total_funding = upgraded.total_funding ?? 0;
|
||||
const net_cost = construction_cost - total_funding;
|
||||
const pc_cost = construction_cost * 0.3; // Placeholder for PC cost
|
||||
const total_sap_uplift = baseline.total_sap_uplift ?? 0;
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 2 — Portfolio AFTER scenario (ALL properties)
|
||||
---------------------------------------------------------- */
|
||||
const portfolioMetricsResult = await db.execute(sql`
|
||||
SELECT
|
||||
AVG(effective_sap)::float AS avg_sap,
|
||||
AVG(effective_carbon)::float AS avg_carbon,
|
||||
AVG(effective_bills)::float AS avg_bills,
|
||||
SUM(effective_carbon)::float AS total_carbon,
|
||||
SUM(effective_bills)::float AS total_bills
|
||||
FROM (
|
||||
SELECT
|
||||
/* ---------- SAP ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
|
||||
ELSE p.current_sap_points
|
||||
END AS effective_sap,
|
||||
|
||||
/* ---------- Carbon ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
|
||||
ELSE e.co2_emissions
|
||||
END AS effective_carbon,
|
||||
|
||||
/* ---------- Bills ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
|
||||
ELSE (
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)
|
||||
END AS effective_bills
|
||||
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM plan
|
||||
WHERE plan.property_id = p.id
|
||||
AND plan.portfolio_id = ${pid}
|
||||
AND plan.scenario_id = ${sid}
|
||||
AND (
|
||||
${hideNonCompliant} = false
|
||||
OR (
|
||||
${minSap}::float IS NOT NULL
|
||||
AND plan.post_sap_points >= ${minSap}::float
|
||||
)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) lp ON true
|
||||
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
) q;
|
||||
`);
|
||||
|
||||
const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 3 — EPC band distribution (ALL properties)
|
||||
---------------------------------------------------------- */
|
||||
const epcRows = await db.execute(sql`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_sap_points
|
||||
ELSE p.current_sap_points
|
||||
END AS effective_sap
|
||||
FROM property p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM plan
|
||||
WHERE plan.property_id = p.id
|
||||
AND plan.portfolio_id = ${pid}
|
||||
AND plan.scenario_id = ${sid}
|
||||
AND (
|
||||
${hideNonCompliant} = false
|
||||
OR (
|
||||
${minSap}::float IS NOT NULL
|
||||
AND plan.post_sap_points >= ${minSap}::float
|
||||
)
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) lp ON true
|
||||
WHERE p.portfolio_id = ${pid};
|
||||
`);
|
||||
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
// EPC band distribution (all properties)
|
||||
// ----------------------------------------------------------
|
||||
//
|
||||
const scenario_epc_counts: Record<string, number> = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
|
|
@ -146,36 +273,43 @@ JOIN property p
|
|||
Unknown: 0,
|
||||
};
|
||||
|
||||
for (const sap of sap_points_array) {
|
||||
const band = sapToEpc(sap);
|
||||
for (const row of epcRows.rows as EpcRow[]) {
|
||||
const band = sapToEpc(row.effective_sap);
|
||||
scenario_epc_counts[band] += 1;
|
||||
}
|
||||
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
// RESPONSE
|
||||
// ----------------------------------------------------------
|
||||
//
|
||||
/* ----------------------------------------------------------
|
||||
RESPONSE
|
||||
---------------------------------------------------------- */
|
||||
|
||||
const constructionCost = upgraded.total_cost ?? 0;
|
||||
const nUpgraded = upgraded.n_units_upgraded ?? 0;
|
||||
const pc_cost = constructionCost * 0.3;
|
||||
|
||||
return NextResponse.json({
|
||||
// Baseline metrics (all units)
|
||||
avg_sap: avg_sap !== null ? Number(avg_sap).toFixed(1) : null,
|
||||
avg_carbon,
|
||||
avg_bills,
|
||||
total_carbon,
|
||||
total_bills,
|
||||
n_units,
|
||||
/* -------- portfolio-after-scenario -------- */
|
||||
avg_sap:
|
||||
portfolioAgg.avg_sap !== null
|
||||
? Number(portfolioAgg.avg_sap).toFixed(1)
|
||||
: null,
|
||||
avg_carbon: portfolioAgg.avg_carbon,
|
||||
avg_bills: portfolioAgg.avg_bills,
|
||||
total_carbon: portfolioAgg.total_carbon,
|
||||
total_bills: portfolioAgg.total_bills,
|
||||
|
||||
/* -------- scenario-only -------- */
|
||||
n_units: scenarioAgg.n_units,
|
||||
n_units_upgraded: nUpgraded,
|
||||
construction_cost: constructionCost,
|
||||
contingency: upgraded.contingency ?? 0,
|
||||
total_funding: upgraded.total_funding ?? 0,
|
||||
net_cost: constructionCost - (upgraded.total_funding ?? 0),
|
||||
total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0,
|
||||
gross_per_unit:
|
||||
nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0,
|
||||
|
||||
/* -------- shared -------- */
|
||||
scenario_epc_counts,
|
||||
pc_cost,
|
||||
// Upgrade metrics (only properties with work)
|
||||
n_units_upgraded,
|
||||
construction_cost,
|
||||
contingency,
|
||||
total_funding,
|
||||
net_cost,
|
||||
gross_per_unit:
|
||||
n_units_upgraded > 0
|
||||
? (construction_cost + pc_cost) / n_units_upgraded
|
||||
: 0,
|
||||
total_sap_uplift,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { scenario } from "@/app/db/schema/recommendations";
|
||||
|
||||
export async function GET(request: NextRequest, props: { params: Promise<{ scenarioId: string }> }) {
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ scenarioId: string }> },
|
||||
) {
|
||||
const params = await props.params;
|
||||
const scenarioId = params.scenarioId;
|
||||
|
||||
|
|
@ -50,7 +53,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena
|
|||
{
|
||||
scenarioName: scenarioName,
|
||||
data: JSON.parse(
|
||||
data[0].epcBreakdownPostRetrofit || "[]"
|
||||
data[0].epcBreakdownPostRetrofit || "[]",
|
||||
) as ChartData[],
|
||||
},
|
||||
],
|
||||
|
|
@ -114,29 +117,49 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena
|
|||
scenarios: [
|
||||
{ scenarioName: scenarioName, data: data[0].costPerUnit || "" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Funding (£)",
|
||||
scenarios: [
|
||||
{ scenarioName: scenarioName, data: "£" + formatNumber(data[0].funding || 0) },
|
||||
{
|
||||
scenarioName: scenarioName,
|
||||
data: "£" + formatNumber(data[0].funding || 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Funding (£)/unit",
|
||||
scenarios: [
|
||||
{ scenarioName: scenarioName, data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)) },
|
||||
{
|
||||
scenarioName: scenarioName,
|
||||
data:
|
||||
"£" +
|
||||
formatNumber(
|
||||
(data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Contingency (£)",
|
||||
scenarios: [
|
||||
{ scenarioName: scenarioName, data: "£" + formatNumber(data[0].contingency || 0) },
|
||||
{
|
||||
scenarioName: scenarioName,
|
||||
data: "£" + formatNumber(data[0].contingency || 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Contingency (£)/unit",
|
||||
scenarios: [
|
||||
{ scenarioName: scenarioName, data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)) },
|
||||
{
|
||||
scenarioName: scenarioName,
|
||||
data:
|
||||
"£" +
|
||||
formatNumber(
|
||||
(data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ export const PortfolioGoal: [string, ...string[]] = [
|
|||
"Energy Savings",
|
||||
"None",
|
||||
];
|
||||
export type PortfolioGoalType = (typeof PortfolioGoal)[number];
|
||||
export const PORTFOLIO_GOALS = {
|
||||
EPC: "Increasing EPC",
|
||||
VALUATION: "Valuation Improvement",
|
||||
CO2: "Reducing CO2 emissions",
|
||||
ENERGY: "Energy Savings",
|
||||
NONE: "None",
|
||||
} satisfies Record<string, PortfolioGoalType>;
|
||||
|
||||
export const PortfolioRole: [string, ...string[]] = [
|
||||
"creator",
|
||||
|
|
@ -79,10 +87,10 @@ export const portfolio = pgTable("portfolio", {
|
|||
energyBillPerUnitPreRetrofit: text("energy_bill_per_unit_pre_retrofit"),
|
||||
energyBillPerUnitPostRetrofit: text("energy_bill_per_unit_post_retrofit"),
|
||||
energyConsumptionPerUnitPreRetrofit: text(
|
||||
"energy_consumption_per_unit_pre_retrofit"
|
||||
"energy_consumption_per_unit_pre_retrofit",
|
||||
),
|
||||
energyConsumptionPerUnitPostRetrofit: text(
|
||||
"energy_consumption_per_unit_post_retrofit"
|
||||
"energy_consumption_per_unit_post_retrofit",
|
||||
),
|
||||
valuationImprovementPerUnit: text("valuation_improvement_per_unit"),
|
||||
costPerUnit: text("cost_per_unit"),
|
||||
|
|
|
|||
|
|
@ -9,23 +9,29 @@ import {
|
|||
} from "@/app/shadcn_components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
import { formatNumber, sapToEpc } from "@/app/utils";
|
||||
import type {
|
||||
AverageMetrics,
|
||||
EstimatedCounts,
|
||||
TotalMetrics,
|
||||
ScenarioOverlayMetrics,
|
||||
MetricKey,
|
||||
} from "./types";
|
||||
import type { MetricKey } from "./types";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
|
||||
const cardStyles = {
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Style maps */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
||||
const cardStyles: Record<
|
||||
MetricKey,
|
||||
{ icon: React.ComponentType<any>; color: string }
|
||||
> = {
|
||||
totalHomes: { icon: Home, color: "text-purple-600" },
|
||||
avgSap: { icon: LineChart, color: "text-blue-600" },
|
||||
avgCarbon: { icon: Leaf, color: "text-emerald-600" },
|
||||
avgBills: { icon: Zap, color: "text-amber-600" },
|
||||
missingEpc: { icon: FileQuestionIcon, color: "text-red-600" },
|
||||
} as Record<MetricKey, { icon: React.ComponentType<any>; color: string }>;
|
||||
};
|
||||
|
||||
const epcColors: Record<string, string> = {
|
||||
A: "text-epc_a",
|
||||
|
|
@ -38,24 +44,38 @@ const epcColors: Record<string, string> = {
|
|||
Unknown: "text-gray-400",
|
||||
};
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Helpers */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
||||
function hasOverlay(
|
||||
overlay: ScenarioOverlayMetrics | undefined
|
||||
overlay: ScenarioOverlayMetrics | undefined,
|
||||
): overlay is ScenarioOverlayMetrics {
|
||||
return overlay !== undefined;
|
||||
}
|
||||
|
||||
function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded bg-gray-200 ${className}`} />;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Component */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
||||
export function DashboardSummaryCards({
|
||||
total,
|
||||
totals,
|
||||
averages,
|
||||
estimatedCounts,
|
||||
scenarioOverlay,
|
||||
loading = false,
|
||||
}: {
|
||||
total: number;
|
||||
totals: TotalMetrics;
|
||||
averages: AverageMetrics;
|
||||
estimatedCounts: EstimatedCounts;
|
||||
scenarioOverlay?: ScenarioOverlayMetrics | null;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const missingEpcCount = estimatedCounts.estimated;
|
||||
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
|
||||
|
|
@ -66,10 +86,7 @@ export function DashboardSummaryCards({
|
|||
const hasScenario = hasOverlay(overlay);
|
||||
|
||||
function deltaLabel(baseline: number, scenario: number) {
|
||||
const b = Number(baseline);
|
||||
const s = Number(scenario);
|
||||
const diff = s - b;
|
||||
|
||||
const diff = scenario - baseline;
|
||||
if (!isFinite(diff) || diff === 0) return null;
|
||||
|
||||
const sign = diff > 0 ? "▲" : "▼";
|
||||
|
|
@ -87,10 +104,6 @@ export function DashboardSummaryCards({
|
|||
key: "totalHomes",
|
||||
title: "Number of Homes",
|
||||
baseline: total,
|
||||
scenario: null,
|
||||
baselineTotal: undefined,
|
||||
scenarioTotal: undefined,
|
||||
units: "",
|
||||
subtitle: "Total properties in this portfolio.",
|
||||
},
|
||||
{
|
||||
|
|
@ -100,8 +113,6 @@ export function DashboardSummaryCards({
|
|||
scenario:
|
||||
overlay?.avgSap &&
|
||||
`${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`,
|
||||
baselineTotal: undefined,
|
||||
scenarioTotal: undefined,
|
||||
subtitle: "Current SAP rating across all properties.",
|
||||
isEpc: true,
|
||||
},
|
||||
|
|
@ -144,92 +155,110 @@ export function DashboardSummaryCards({
|
|||
return (
|
||||
<Card
|
||||
key={c.key}
|
||||
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
|
||||
className="h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
{/* Header */}
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-1">
|
||||
<motion.div whileHover={{ scale: 1.05 }}>
|
||||
<Icon className={`h-5 w-5 ${color}`} />
|
||||
</motion.div>
|
||||
<CardTitle className="text-md font-medium text-gray-700">
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
{loading ? (
|
||||
<>
|
||||
<Skeleton className="h-5 w-5 rounded-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div whileHover={{ scale: 1.05 }}>
|
||||
<Icon className={`h-5 w-5 ${color}`} />
|
||||
</motion.div>
|
||||
<CardTitle className="text-md font-medium text-gray-700">
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
</>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className="flex flex-1 flex-col gap-2">
|
||||
{/* BASELINE + SCENARIO ROW */}
|
||||
<div
|
||||
className={`flex ${
|
||||
hasScenario ? "justify-between" : "justify-start"
|
||||
} items-start`}
|
||||
>
|
||||
{/* BASELINE COLUMN */}
|
||||
{/* Baseline */}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500">Baseline</span>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={
|
||||
c.isEpc
|
||||
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
|
||||
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue print-text-solid"
|
||||
}
|
||||
>
|
||||
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
|
||||
</span>
|
||||
|
||||
{/* units next to baseline average */}
|
||||
{c.units && (
|
||||
<span className="text-sm text-gray-500">{c.units}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Baseline total */}
|
||||
{c.baselineTotal !== undefined && (
|
||||
<span className="text-md text-gray-600">
|
||||
Total:{" "}
|
||||
{c.key === "avgBills"
|
||||
? `£${formatNumber(c.baselineTotal)}`
|
||||
: `${formatNumber(c.baselineTotal)} tCO₂e`}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-28 mt-1" />
|
||||
) : (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={
|
||||
c.isEpc
|
||||
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
|
||||
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue"
|
||||
}
|
||||
>
|
||||
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
|
||||
</span>
|
||||
{c.units && (
|
||||
<span className="text-sm text-gray-500">{c.units}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.baselineTotal !== undefined &&
|
||||
(loading ? (
|
||||
<Skeleton className="h-4 w-36 mt-1" />
|
||||
) : (
|
||||
<span className="text-md text-gray-600">
|
||||
Total:{" "}
|
||||
{c.key === "avgBills"
|
||||
? `£${formatNumber(c.baselineTotal)}`
|
||||
: `${formatNumber(c.baselineTotal)} tCO₂e`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SCENARIO COLUMN */}
|
||||
{/* Scenario */}
|
||||
{hasScenario && c.scenario && (
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-xs text-gray-500">Scenario</span>
|
||||
|
||||
{/* average + delta + units row */}
|
||||
<div className="flex items-baseline justify-end gap-2">
|
||||
<span
|
||||
className={
|
||||
c.isEpc
|
||||
? `text-2xl font-semibold ${
|
||||
epcColors[
|
||||
sapToEpc(
|
||||
overlay?.avgSap?.scenario ??
|
||||
(averages.avg_sap || 0)
|
||||
) || "Unknown"
|
||||
]
|
||||
}`
|
||||
: "text-2xl font-semibold text-brandblue"
|
||||
}
|
||||
>
|
||||
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
|
||||
</span>
|
||||
|
||||
{c.delta && <span>{c.delta}</span>}
|
||||
</div>
|
||||
|
||||
{/* Scenario total */}
|
||||
{c.scenarioTotal !== undefined && (
|
||||
<span className="text-md text-gray-600">
|
||||
Total:{" "}
|
||||
{c.key === "avgBills"
|
||||
? `£${formatNumber(c.scenarioTotal)}`
|
||||
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Skeleton className="h-7 w-24 mt-1 ml-auto" />
|
||||
) : (
|
||||
<div className="flex items-baseline justify-end gap-2">
|
||||
<span
|
||||
className={
|
||||
c.isEpc
|
||||
? `text-2xl font-semibold ${
|
||||
epcColors[
|
||||
sapToEpc(
|
||||
overlay?.avgSap?.scenario ??
|
||||
(averages.avg_sap || 0),
|
||||
) || "Unknown"
|
||||
]
|
||||
}`
|
||||
: "text-2xl font-semibold text-brandblue"
|
||||
}
|
||||
>
|
||||
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
|
||||
</span>
|
||||
{c.delta && <span>{c.delta}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.scenarioTotal !== undefined &&
|
||||
(loading ? (
|
||||
<Skeleton className="h-4 w-36 mt-1 ml-auto" />
|
||||
) : (
|
||||
<span className="text-md text-gray-600">
|
||||
Total:{" "}
|
||||
{c.key === "avgBills"
|
||||
? `£${formatNumber(c.scenarioTotal)}`
|
||||
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -246,7 +275,11 @@ export function DashboardSummaryCards({
|
|||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
|
||||
import { DashboardSummaryCards } from "./DashboardSummaryCards";
|
||||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
PropertyTypeCount,
|
||||
ScenarioSummary,
|
||||
} from "./types";
|
||||
import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons";
|
||||
|
||||
interface ReportingClientAreaProps {
|
||||
baseline: BaselineMetrics;
|
||||
|
|
@ -35,12 +36,17 @@ interface ReportingClientAreaProps {
|
|||
async function fetchScenarioReport({
|
||||
portfolioId,
|
||||
scenarioId,
|
||||
hideNonCompliant,
|
||||
}: {
|
||||
portfolioId: number;
|
||||
scenarioId: number;
|
||||
hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
hideNonCompliant: String(hideNonCompliant),
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`
|
||||
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch scenario report:", await res.text());
|
||||
|
|
@ -57,7 +63,7 @@ async function fetchScenarioMeasures({
|
|||
scenarioId: number;
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`
|
||||
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
@ -74,9 +80,11 @@ export function ReportingClientArea({
|
|||
portfolioId,
|
||||
}: ReportingClientAreaProps) {
|
||||
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
|
||||
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const drawerOpen = Boolean(selectedScenarioId);
|
||||
|
||||
|
|
@ -86,15 +94,24 @@ export function ReportingClientArea({
|
|||
const {
|
||||
data: scenarioData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["scenario-report", portfolioId, selectedScenarioId],
|
||||
queryKey: [
|
||||
"scenario-report",
|
||||
portfolioId,
|
||||
selectedScenarioId,
|
||||
appliedHideNonCompliant,
|
||||
],
|
||||
queryFn: () =>
|
||||
fetchScenarioReport({
|
||||
portfolioId,
|
||||
scenarioId: selectedScenarioId!,
|
||||
hideNonCompliant: appliedHideNonCompliant,
|
||||
}),
|
||||
enabled: !!selectedScenarioId, // only run when scenario selected
|
||||
keepPreviousData: true, // keep showing old data while loading new scenario or applying filter
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -109,10 +126,10 @@ export function ReportingClientArea({
|
|||
scenarioId: selectedScenarioId!,
|
||||
}),
|
||||
enabled: measuresOpen && !!selectedScenarioId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const scenarioLoading = isLoading && !!selectedScenarioId;
|
||||
|
||||
// ----------------------------------------
|
||||
// Build overlay for Dashboard Summary cards
|
||||
// ----------------------------------------
|
||||
|
|
@ -145,40 +162,45 @@ export function ReportingClientArea({
|
|||
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
|
||||
// ----------------------------------------
|
||||
|
||||
const scenarioSpecific = scenarioData
|
||||
? {
|
||||
constructionCost: scenarioData.construction_cost,
|
||||
pcCost: scenarioData.pc_cost,
|
||||
contingency: scenarioData.contingency,
|
||||
funding: scenarioData.total_funding,
|
||||
costPerSap:
|
||||
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
scenarioData.total_sap_uplift
|
||||
: 0,
|
||||
costPerCo2:
|
||||
scenarioData.construction_cost > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
|
||||
: 0,
|
||||
netCost: scenarioData.net_cost,
|
||||
grossPerUnit: scenarioData.gross_per_unit,
|
||||
nUnits: scenarioData.n_units_upgraded,
|
||||
totalCarbonSaved:
|
||||
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
|
||||
totalBillsSaved:
|
||||
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
|
||||
averageCaribonSaved:
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
|
||||
scenarioData.n_units_upgraded,
|
||||
averageBillsSaved:
|
||||
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
|
||||
scenarioData.n_units_upgraded,
|
||||
}
|
||||
: null;
|
||||
const scenarioSpecific = useMemo(() => {
|
||||
if (!scenarioData) return null;
|
||||
|
||||
return {
|
||||
constructionCost: scenarioData.construction_cost,
|
||||
pcCost: scenarioData.pc_cost,
|
||||
contingency: scenarioData.contingency,
|
||||
funding: scenarioData.total_funding,
|
||||
costPerSap:
|
||||
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
scenarioData.total_sap_uplift
|
||||
: 0,
|
||||
costPerCo2:
|
||||
scenarioData.construction_cost > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
|
||||
: 0,
|
||||
netCost: scenarioData.net_cost,
|
||||
grossPerUnit: scenarioData.gross_per_unit,
|
||||
nUnits: scenarioData.n_units_upgraded,
|
||||
totalCarbonSaved:
|
||||
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
|
||||
totalBillsSaved:
|
||||
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
|
||||
averageCaribonSaved:
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
|
||||
scenarioData.n_units_upgraded,
|
||||
averageBillsSaved:
|
||||
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
|
||||
scenarioData.n_units_upgraded,
|
||||
};
|
||||
}, [scenarioData, baseline]);
|
||||
|
||||
// Baseline stays baseline
|
||||
const activeMetrics = baseline;
|
||||
|
||||
const scenarioBusy = !!selectedScenarioId && (isLoading || isFetching);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
|
|
@ -193,33 +215,43 @@ export function ReportingClientArea({
|
|||
{/* RIGHT: Actions (only when scenario selected) */}
|
||||
{selectedScenarioId && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show measures */}
|
||||
<button
|
||||
onClick={() => setMeasuresOpen(true)}
|
||||
disabled={scenarioLoading}
|
||||
disabled={true}
|
||||
className={`
|
||||
rounded-md px-3 py-2 text-sm font-medium transition
|
||||
${
|
||||
scenarioLoading
|
||||
scenarioBusy
|
||||
? "bg-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: "bg-brandblue text-white hover:bg-hoverblue"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{scenarioLoading ? "Loading…" : "Show measures"}
|
||||
{scenarioBusy ? "Loading…" : "Show measures"}
|
||||
</button>
|
||||
|
||||
<ReportingFunctionalityButtons
|
||||
hideNonCompliant={appliedHideNonCompliant}
|
||||
disabled={scenarioBusy}
|
||||
onApply={async (value) => {
|
||||
setAppliedHideNonCompliant(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Download PDF */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`,
|
||||
"_blank"
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
disabled={scenarioLoading}
|
||||
disabled={scenarioBusy}
|
||||
className={`
|
||||
rounded-md border px-3 py-2 text-sm font-medium transition
|
||||
${
|
||||
scenarioLoading
|
||||
scenarioBusy
|
||||
? "border-gray-200 text-gray-400 cursor-not-allowed"
|
||||
: "hover:bg-gray-50"
|
||||
}
|
||||
|
|
@ -247,7 +279,11 @@ export function ReportingClientArea({
|
|||
subtitle="High-level insights on performance, energy, and EPC quality."
|
||||
/>
|
||||
|
||||
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
|
||||
<ScenarioFinancialDrawer
|
||||
open={drawerOpen}
|
||||
metrics={scenarioSpecific}
|
||||
loading={scenarioBusy}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
|
||||
<DashboardSummaryCards
|
||||
|
|
@ -256,6 +292,7 @@ export function ReportingClientArea({
|
|||
averages={activeMetrics.averages}
|
||||
estimatedCounts={activeMetrics.estimatedCounts}
|
||||
scenarioOverlay={scenarioOverlay}
|
||||
loading={scenarioBusy}
|
||||
/>
|
||||
|
||||
<BreakdownChart
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/app/shadcn_components/ui/dropdown-menu";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
|
||||
|
||||
export interface ReportingFunctionalityButtonsProps {
|
||||
/** Currently applied value */
|
||||
hideNonCompliant: boolean;
|
||||
|
||||
/**
|
||||
* Explicit user action.
|
||||
* Parent decides what "apply" means (refetch, mutate, etc).
|
||||
*/
|
||||
onApply: (value: boolean) => Promise<void> | void;
|
||||
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ReportingFunctionalityButtons({
|
||||
hideNonCompliant,
|
||||
onApply,
|
||||
disabled = false,
|
||||
}: ReportingFunctionalityButtonsProps) {
|
||||
const [draftHideNonCompliant, setDraftHideNonCompliant] =
|
||||
useState<boolean>(hideNonCompliant);
|
||||
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
|
||||
async function handleApply() {
|
||||
try {
|
||||
setIsApplying(true);
|
||||
await onApply(draftHideNonCompliant);
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
try {
|
||||
// reset the filter and trigger the fetch
|
||||
setIsApplying(true);
|
||||
setDraftHideNonCompliant(false);
|
||||
await onApply(false);
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setDraftHideNonCompliant(hideNonCompliant);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || isApplying}
|
||||
className={`
|
||||
relative flex items-center gap-2
|
||||
${
|
||||
hideNonCompliant
|
||||
? "border-brandmidblue/40 bg-brandlightblue/40"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Filter icon */}
|
||||
<svg
|
||||
className={`h-4 w-4 ${
|
||||
hideNonCompliant ? "text-brandmidblue" : "text-gray-500"
|
||||
}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M3 4a1 1 0 011-1h12a1 1 0 01.8 1.6l-4.8 6.4V16a1 1 0 01-1.447.894l-2-1A1 1 0 018 14v-2.999L3.2 5.6A1 1 0 013 4z" />
|
||||
</svg>
|
||||
Filter options
|
||||
{hideNonCompliant && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-brandmidblue" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-80 p-4 shadow-tremor-dropdown"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Filter option */}
|
||||
<div className="flex items-start gap-4">
|
||||
<Checkbox
|
||||
id="hide-non-compliant"
|
||||
checked={draftHideNonCompliant}
|
||||
onCheckedChange={(checked) =>
|
||||
setDraftHideNonCompliant(Boolean(checked))
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="hide-non-compliant"
|
||||
className="cursor-pointer space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug">
|
||||
<svg
|
||||
className="h-4 w-4 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 00-1.414 1.414L9 13l4.707-4.707z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Hide non-compliant properties
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 leading-relaxed">
|
||||
Exclude properties that don’t meet the defined upgrade targets
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isApplying}
|
||||
onClick={handleReset}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-brandmidblue hover:bg-hoverblue"
|
||||
disabled={isApplying}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{isApplying ? "Applying…" : "Apply filters"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import { Gauge } from "lucide-react";
|
|||
interface ScenarioFinancialDrawerProps {
|
||||
open: boolean;
|
||||
metrics: any | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
|
@ -56,7 +57,7 @@ function GradientCard({
|
|||
className={clsx(
|
||||
"relative rounded-lg p-[2px] gradient-card",
|
||||
gradient,
|
||||
`gradient-${variant}`
|
||||
`gradient-${variant}`,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-[7px] bg-white h-full">{children}</div>
|
||||
|
|
@ -75,6 +76,7 @@ function Metric({
|
|||
color,
|
||||
gradient,
|
||||
variant = "green",
|
||||
loading = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
|
|
@ -82,15 +84,38 @@ function Metric({
|
|||
color: string;
|
||||
gradient: string;
|
||||
variant?: "green" | "blue" | "purple";
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading || !value) {
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="p-4 h-full animate-pulse">
|
||||
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="flex flex-col items-center justify-center p-4 h-full text-center">
|
||||
<Icon className={clsx("h-6 w-6 mb-2", color)} />
|
||||
<span className="text-3xl font-semibold text-gray-900">{value}</span>
|
||||
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="w-full animate-pulse space-y-3">
|
||||
<div className="h-6 w-6 mx-auto rounded bg-gray-200" />
|
||||
<div className="h-8 w-2/3 mx-auto rounded bg-gray-200" />
|
||||
<div className="h-3 w-1/2 mx-auto rounded bg-gray-200" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Icon className={clsx("h-6 w-6 mb-2", color)} />
|
||||
<span className="text-3xl font-semibold text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
|
|
@ -108,6 +133,7 @@ function PairedMetric({
|
|||
gradient,
|
||||
iconClassName = "text-gray-700",
|
||||
variant = "green",
|
||||
loading = false,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
|
|
@ -116,30 +142,62 @@ function PairedMetric({
|
|||
gradient: string;
|
||||
iconClassName?: string;
|
||||
variant?: "green" | "blue" | "purple";
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading || !primary.value || !secondary.value) {
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="p-4 h-full animate-pulse">
|
||||
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="p-4 h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={clsx("h-5 w-5", iconClassName)} />
|
||||
<span className="text-sm font-semibold text-gray-900">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{primary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{primary.value}
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-1/3 rounded bg-gray-200" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-gray-200" />
|
||||
<div className="h-6 w-full rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-gray-200" />
|
||||
<div className="h-6 w-full rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={clsx("h-5 w-5", iconClassName)} />
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{secondary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{secondary.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{primary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{primary.value}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{secondary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{secondary.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
|
|
@ -172,7 +230,7 @@ function Section({
|
|||
<div
|
||||
className={clsx(
|
||||
"rounded-lg p-2 bg-white shadow-sm border",
|
||||
accentColor
|
||||
accentColor,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
|
|
@ -191,6 +249,22 @@ function Section({
|
|||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Loading Skeleton for dashboard cards */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
||||
function LoadingOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 rounded-lg bg-white/70 backdrop-blur-sm">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-6 animate-pulse">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-lg bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Main Drawer */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
|
@ -198,10 +272,11 @@ function Section({
|
|||
export function ScenarioFinancialDrawer({
|
||||
open,
|
||||
metrics,
|
||||
loading = false,
|
||||
}: ScenarioFinancialDrawerProps) {
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open && metrics && (
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
|
|
@ -228,14 +303,17 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-green-700"
|
||||
primary={{
|
||||
label: "Total carbon saved (t/yr)",
|
||||
value: formatNumber(metrics.totalCarbonSaved),
|
||||
value: metrics ? formatNumber(metrics.totalCarbonSaved) : "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "Average per unit (t/yr)",
|
||||
value: formatNumber(metrics.averageCaribonSaved),
|
||||
value: metrics
|
||||
? formatNumber(metrics.averageCaribonSaved)
|
||||
: "",
|
||||
}}
|
||||
gradient={gradients.green}
|
||||
variant="green"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<PairedMetric
|
||||
|
|
@ -244,23 +322,29 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-green-700"
|
||||
primary={{
|
||||
label: "Total bill savings (£/yr)",
|
||||
value: `£${formatNumber(metrics.totalBillsSaved)}`,
|
||||
value: metrics
|
||||
? `£${formatNumber(metrics.totalBillsSaved)}`
|
||||
: "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "Average per unit (£/yr)",
|
||||
value: `£${formatNumber(metrics.averageBillsSaved)}`,
|
||||
value: metrics
|
||||
? `£${formatNumber(metrics.averageBillsSaved)}`
|
||||
: "",
|
||||
}}
|
||||
gradient={gradients.green}
|
||||
variant="green"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Homes upgraded"
|
||||
value={metrics.nUnits}
|
||||
value={metrics ? metrics.nUnits : ""}
|
||||
icon={HomeIcon}
|
||||
color="text-green-700"
|
||||
gradient={gradients.green}
|
||||
variant="green"
|
||||
loading={loading}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
|
@ -278,32 +362,37 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-blue-600"
|
||||
primary={{
|
||||
label: "Construction works",
|
||||
value: `£${formatNumber(metrics.constructionCost)}`,
|
||||
value: metrics
|
||||
? `£${formatNumber(metrics.constructionCost)}`
|
||||
: "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "Project delivery",
|
||||
value: `£${formatNumber(metrics.pcCost)}`,
|
||||
value: metrics ? `£${formatNumber(metrics.pcCost)}` : "",
|
||||
}}
|
||||
gradient={gradients.blue}
|
||||
variant="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Gross cost per unit"
|
||||
value={`£${formatNumber(metrics.grossPerUnit)}`}
|
||||
value={metrics ? `£${formatNumber(metrics.grossPerUnit)}` : ""}
|
||||
icon={HomeIcon}
|
||||
color="text-blue-600"
|
||||
gradient={gradients.blue}
|
||||
variant="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Contingency"
|
||||
value={`£${formatNumber(metrics.contingency)}`}
|
||||
value={metrics ? `£${formatNumber(metrics.contingency)}` : ""}
|
||||
icon={Gauge}
|
||||
color="text-blue-600"
|
||||
gradient={gradients.blue}
|
||||
variant="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
|
@ -321,14 +410,15 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-purple-700"
|
||||
primary={{
|
||||
label: "£ per SAP point",
|
||||
value: `£${formatNumber(metrics.costPerSap)}`,
|
||||
value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "£ per tonne CO₂",
|
||||
value: `£${formatNumber(metrics.costPerCo2)}`,
|
||||
value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "",
|
||||
}}
|
||||
gradient={gradients.purple}
|
||||
variant="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export async function getPortfolioCounts(portfolioId: number): Promise<number> {
|
|||
}
|
||||
|
||||
export async function getAverages(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<AverageMetrics> {
|
||||
const result = await db.execute<AverageMetrics>(sql`
|
||||
SELECT
|
||||
|
|
@ -69,7 +69,7 @@ export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
|
|||
}
|
||||
|
||||
export async function getCountByAgeBand(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<AgeBandCount[]> {
|
||||
const result = await db.execute<AgeBandCount>(sql`
|
||||
SELECT
|
||||
|
|
@ -96,23 +96,27 @@ export async function getCountByAgeBand(
|
|||
}
|
||||
|
||||
export async function getCountByEpcBand(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<EpcBandCount[]> {
|
||||
const result = await db.execute<EpcBandCount>(sql`
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
SELECT
|
||||
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
|
||||
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual,
|
||||
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated
|
||||
COUNT(*) FILTER (
|
||||
WHERE e.estimated = false OR e.estimated IS NULL
|
||||
)::int AS actual,
|
||||
COUNT(*) FILTER (
|
||||
WHERE e.estimated = true
|
||||
)::int AS estimated
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY epc
|
||||
) q
|
||||
ORDER BY
|
||||
CASE
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.epc = 'A' THEN 1
|
||||
WHEN q.epc = 'B' THEN 2
|
||||
WHEN q.epc = 'C' THEN 3
|
||||
|
|
@ -120,7 +124,7 @@ export async function getCountByEpcBand(
|
|||
WHEN q.epc = 'E' THEN 5
|
||||
WHEN q.epc = 'F' THEN 6
|
||||
WHEN q.epc = 'G' THEN 7
|
||||
ELSE 8 -- 'Unknown'
|
||||
ELSE 8
|
||||
END;
|
||||
`);
|
||||
|
||||
|
|
@ -128,7 +132,7 @@ export async function getCountByEpcBand(
|
|||
}
|
||||
|
||||
export async function getEstimatedCounts(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<EstimatedCounts> {
|
||||
const result = await db.execute<EstimatedCounts>(sql`
|
||||
SELECT
|
||||
|
|
@ -142,7 +146,7 @@ export async function getEstimatedCounts(
|
|||
}
|
||||
|
||||
export async function getCountByPropertyType(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<PropertyTypeCount[]> {
|
||||
const result = await db.execute<PropertyTypeCount>(sql`
|
||||
SELECT property_type AS type, COUNT(*)::int AS count
|
||||
|
|
@ -173,7 +177,7 @@ export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
|
|||
}
|
||||
|
||||
export async function getLikelyDowngrades(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<number> {
|
||||
const result = await db.execute<{ downgrades: number }>(sql`
|
||||
SELECT
|
||||
|
|
@ -192,7 +196,7 @@ export async function getLikelyDowngrades(
|
|||
}
|
||||
|
||||
export async function loadBaselineMetrics(
|
||||
portfolioId: number
|
||||
portfolioId: number,
|
||||
): Promise<BaselineMetrics> {
|
||||
const [
|
||||
total,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) {
|
|||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Protect only your app’s authenticated areas
|
||||
// Protect only app’s authenticated areas
|
||||
"/home/:path*",
|
||||
"/portfolio/:path*",
|
||||
"/search/:path*",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue