mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
implemented new ui
This commit is contained in:
parent
eafb2090f9
commit
a5bc1f87e4
6 changed files with 191 additions and 97 deletions
|
|
@ -3,19 +3,21 @@ 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;
|
||||
cost_of_works: number | null;
|
||||
contingency_cost: 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(
|
||||
|
|
@ -23,72 +25,83 @@ export async function GET(
|
|||
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
|
||||
) {
|
||||
const { portfolioId, scenarioId } = await props.params;
|
||||
|
||||
const pid = BigInt(portfolioId);
|
||||
const sid = BigInt(scenarioId);
|
||||
|
||||
// Fetch all plans
|
||||
const planRows = await db.execute(sql`
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
// QUERY 1 — Baseline metrics for *all* properties
|
||||
// ----------------------------------------------------------
|
||||
//
|
||||
const baselineResult = 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,
|
||||
cost_of_works,
|
||||
contingency_cost
|
||||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND scenario_id = ${sid};
|
||||
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,
|
||||
|
||||
ARRAY_AGG(post_sap_points) AS sap_points_array
|
||||
|
||||
FROM plan p
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
AND p.scenario_id = ${sid};
|
||||
`);
|
||||
|
||||
const plans = planRows.rows as PlanRow[];
|
||||
const baseline = baselineResult.rows[0] as BaselineAggregates | undefined;
|
||||
|
||||
if (plans.length === 0) {
|
||||
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`
|
||||
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 plan p
|
||||
LEFT JOIN funding_package fp ON fp.plan_id = p.id
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
AND p.scenario_id = ${sid}
|
||||
AND p.cost_of_works > 0.01;
|
||||
`);
|
||||
|
||||
// Averages + totals
|
||||
const n = plans.length;
|
||||
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
|
||||
|
||||
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;
|
||||
const n_units_upgraded = upgraded.n_units_upgraded;
|
||||
const total_cost = upgraded.total_cost ?? 0;
|
||||
const contingency = upgraded.contingency ?? 0;
|
||||
const total_funding = upgraded.total_funding ?? 0;
|
||||
const net_cost = total_cost - total_funding;
|
||||
|
||||
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);
|
||||
|
||||
// Financial
|
||||
const totalCost = plans.reduce((s, r) => s + (r.cost_of_works ?? 0), 0);
|
||||
const contingency = plans.reduce((s, r) => s + (r.contingency_cost ?? 0), 0);
|
||||
const funding = Number(fundingRows.rows[0]?.total_funding ?? 0);
|
||||
const netCost = totalCost - funding;
|
||||
|
||||
// NEW — scenario EPC band counts
|
||||
//
|
||||
// ----------------------------------------------------------
|
||||
// EPC band distribution (all properties)
|
||||
// ----------------------------------------------------------
|
||||
//
|
||||
const scenario_epc_counts: Record<string, number> = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
|
|
@ -100,25 +113,32 @@ 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,
|
||||
|
||||
// Upgrade metrics (only properties with work)
|
||||
n_units_upgraded,
|
||||
total_cost,
|
||||
contingency,
|
||||
total_funding,
|
||||
net_cost,
|
||||
net_cost_per_unit: n_units_upgraded > 0 ? net_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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ export function ReportingClientArea({
|
|||
// ----------------------------------------
|
||||
// Build overlay for Dashboard Summary cards
|
||||
// ----------------------------------------
|
||||
console.log("scenarioData", scenarioData);
|
||||
|
||||
const scenarioOverlay = scenarioData
|
||||
? {
|
||||
avgSap: {
|
||||
|
|
@ -122,7 +124,7 @@ export function ReportingClientArea({
|
|||
: 0,
|
||||
netCost: scenarioData.net_cost,
|
||||
netCostPerUnit: scenarioData.net_cost_per_unit,
|
||||
nUnits: scenarioData.n_units,
|
||||
nUnits: scenarioData.n_units_upgraded,
|
||||
}
|
||||
: null;
|
||||
|
||||
|
|
@ -176,6 +178,8 @@ export function ReportingClientArea({
|
|||
<EpcQualityCards
|
||||
estimatedCounts={activeMetrics.estimatedCounts}
|
||||
total={activeMetrics.total}
|
||||
expiredEpcs={activeMetrics.expiredEpcs}
|
||||
likelyDowngrades={activeMetrics.likelyDowngrades}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -153,18 +153,58 @@ 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 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 +213,8 @@ export async function loadBaselineMetrics(
|
|||
ageBands,
|
||||
epcBands,
|
||||
estimatedCounts,
|
||||
expiredEpcs,
|
||||
likelyDowngrades,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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