From a5bc1f87e4b553372bf3c0ac31fb244c83e04925 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Dec 2025 23:25:49 +0000 Subject: [PATCH] implemented new ui --- .../scenario/[scenarioId]/metrics/route.ts | 164 ++++++++++-------- src/app/components/portfolio/Toolbar.tsx | 6 +- .../(portfolio)/reporting/EpcQualityCards.tsx | 50 ++++-- .../reporting/ReportingClientArea.tsx | 6 +- .../reporting/databaseFunctions.ts | 60 ++++++- .../[slug]/(portfolio)/reporting/types.ts | 2 + 6 files changed, 191 insertions(+), 97 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index b6abc39b..a4cfcc31 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -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 = { 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, }); } diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 6f3af363..d9ff0df8 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -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", diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx index 958ff5b4..89893137 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx @@ -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 ( -
+
{cards.map((c) => { const Icon = c.icon; return ( + {/* Header */}
@@ -65,12 +89,13 @@ export function EpcQualityCards({ + {/* Content */}
{c.value}
- {/* Correct mini bar per card */} + {/* Mini bar */}
+ {/* Footer */}

{c.subtitle}

diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index fa452404..f8d858c3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -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({
diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index e6e48625..842af3a0 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -153,18 +153,58 @@ export async function getCountByPropertyType( return result.rows; } +export async function getExpiredEpcCount(portfolioId: number): Promise { + 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 { + 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 { - 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, }; } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts index 67da0aac..4aec373c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts @@ -40,6 +40,8 @@ export interface BaselineMetrics { ageBands: AgeBandCount[]; epcBands: EpcBandCount[]; estimatedCounts: EstimatedCounts; + expiredEpcs: number; + likelyDowngrades: number; } export type MetricKey =