implemented new ui

This commit is contained in:
Khalim Conn-Kowlessar 2025-12-08 23:25:49 +00:00
parent eafb2090f9
commit a5bc1f87e4
6 changed files with 191 additions and 97 deletions

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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,
};
}

View file

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