From c5b2c80970a02214aab0b9c7f119fc20b47666fe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Dec 2025 23:49:24 +0000 Subject: [PATCH] Added scenario comparison ui - still missing cost --- .../scenario/[scenarioId]/metrics/route.ts | 121 +++++++++++++ src/app/db/schema/recommendations.ts | 6 +- .../reporting/DashboardSummaryCards.tsx | 159 +++++++++++------- .../reporting/ReportingClientArea.tsx | 139 +++++++++------ .../[slug]/(portfolio)/reporting/page.tsx | 9 - .../[slug]/(portfolio)/reporting/types.ts | 11 +- 6 files changed, 315 insertions(+), 130 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts new file mode 100644 index 0000000..e2a4b3d --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -0,0 +1,121 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +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; +}; + +export async function GET( + request: NextRequest, + props: { params: Promise<{ portfolioId: string; scenarioId: string }> } +) { + console.log("In the request "); + const { portfolioId, scenarioId } = await props.params; + + const pid = BigInt(portfolioId); + const sid = BigInt(scenarioId); + + // + // -------------------------------------------------------------- + // 1️⃣ Fetch all plans for this portfolio + scenario (FAST) + // -------------------------------------------------------------- + // + const planRows = await db.execute(sql` + SELECT + id, + post_sap_points, + post_co2_emissions, + post_energy_bill, + post_energy_consumption, + valuation_post_retrofit, + valuation_increase, + co2_savings, + energy_bill_savings, + energy_consumption_savings + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid}; + `); + + const plans = planRows.rows as PlanRow[]; + + if (plans.length === 0) { + return NextResponse.json( + { error: "No plans found for this scenario" }, + { status: 404 } + ); + } + + const planIds = plans.map((p) => p.id); + + // + // -------------------------------------------------------------- + // 2️⃣ Fetch total funding for all planIds + // funding = SUM(project_funding + total_uplift) + // -------------------------------------------------------------- + // + + 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}); +`); + + // + // -------------------------------------------------------------- + // 3️⃣ Aggregate scenario metrics (SAP, carbon, bills) + // -------------------------------------------------------------- + // + const n = plans.length; + + 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 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); + + // + // -------------------------------------------------------------- + // 4️⃣ Financial Metrics (based on real funding) + // -------------------------------------------------------------- + // + const totalCost = 0; // you will add "plan.cost" later + const contingency = 0; // also update when you have it + const funding = Number(fundingRows.rows[0]?.total_funding ?? 0); + const netCost = Number(totalCost) - funding; + + return NextResponse.json({ + avg_sap: avg_sap.toFixed(1), + 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, + }); +} diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index faee042..32a2e0c 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -82,7 +82,6 @@ export const planTypeEnum = pgEnum("plan_type", PlanType); export const plan = pgTable("plan", { id: bigserial("id", { mode: "bigint" }).primaryKey(), - name: text("name"), portfolioId: bigint("portfolio_id", { mode: "bigint" }) @@ -100,6 +99,9 @@ export const plan = pgTable("plan", { createdAt: timestamp("created_at").notNull().defaultNow(), isDefault: boolean("is_default").notNull(), + totalCost: real("total_cost"), + contingency: real("contingency"), + // ───────────────────────────────────────────────────────── // Valuation metrics (existing) // ───────────────────────────────────────────────────────── @@ -126,7 +128,7 @@ export const plan = pgTable("plan", { energyBillSavings: real("energy_bill_savings"), // ───────────────────────────────────────────────────────── - // NEW — Energy demand (kWh/year) + // NEW — Energy consumption (kWh/year) // ───────────────────────────────────────────────────────── postEnergyConsumption: real("post_energy_consumption"), energyConsumptionSavings: real("energy_consumption_savings"), diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx index 34669af..15c9a92 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx @@ -38,6 +38,12 @@ const epcColors: Record = { Unknown: "text-gray-400", }; +function hasOverlay( + overlay: ScenarioOverlayMetrics | undefined +): overlay is ScenarioOverlayMetrics { + return overlay !== undefined; +} + export function DashboardSummaryCards({ total, totals, @@ -53,21 +59,25 @@ export function DashboardSummaryCards({ }) { const missingEpcCount = estimatedCounts.estimated; const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0; - const averageCurrentEpc = sapToEpc(averages.avg_sap || 0); - const hasScenario = Boolean(scenarioOverlay); - // We pull the scenario data from the scenario overlay + const averageCurrentEpc = sapToEpc(averages.avg_sap || 0); + + const overlay = scenarioOverlay ?? undefined; + const hasScenario = hasOverlay(overlay); function deltaLabel(baseline: number, scenario: number) { - const diff = scenario - baseline; - if (diff === 0) return null; + const b = Number(baseline); + const s = Number(scenario); + const diff = s - b; + + if (!isFinite(diff) || diff === 0) return null; const sign = diff > 0 ? "▲" : "▼"; const color = diff > 0 ? "text-red-600" : "text-emerald-600"; return ( - {sign} {formatNumber(Math.abs(diff))} + {sign} {Math.abs(diff).toFixed(2)} ); } @@ -78,6 +88,8 @@ export function DashboardSummaryCards({ title: "Number of Homes", baseline: total, scenario: null, + baselineTotal: undefined, + scenarioTotal: undefined, units: "", subtitle: "Total properties in this portfolio.", }, @@ -86,8 +98,10 @@ export function DashboardSummaryCards({ title: "Average EPC Rating", baseline: `${averageCurrentEpc} (${Math.round(averages.avg_sap ?? 0)} pts)`, scenario: - scenarioOverlay?.avgSap && - `${sapToEpc(scenarioOverlay.avgSap.scenario)} (${scenarioOverlay.avgSap.scenario} pts)`, + overlay?.avgSap && + `${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`, + baselineTotal: undefined, + scenarioTotal: undefined, subtitle: "Current SAP rating across all properties.", isEpc: true, }, @@ -95,35 +109,29 @@ export function DashboardSummaryCards({ key: "avgCarbon", title: "Carbon Emissions", baseline: formatNumber(averages.avg_carbon ?? 0), - scenario: - scenarioOverlay?.avgCarbon && - formatNumber(scenarioOverlay.avgCarbon.scenario), - units: "tCO₂e per home", + scenario: overlay?.avgCarbon && formatNumber(overlay.avgCarbon.scenario), + units: "tCO₂e /home", + baselineTotal: totals.total_carbon ?? 0, + scenarioTotal: overlay?.avgCarbon?.scenarioTotal, subtitle: "Average annual CO₂ output per home.", - totalValue: `Total: ${formatNumber(totals.total_carbon ?? 0)} tCO₂e`, delta: - scenarioOverlay?.avgCarbon && - deltaLabel( - scenarioOverlay.avgCarbon.baseline, - scenarioOverlay.avgCarbon.scenario - ), + hasScenario && overlay?.avgCarbon + ? deltaLabel(overlay.avgCarbon.baseline, overlay.avgCarbon.scenario) + : null, }, { key: "avgBills", title: "Energy Bills", baseline: formatNumber(averages.avg_bills ?? 0), - scenario: - scenarioOverlay?.avgBills && - formatNumber(scenarioOverlay.avgBills.scenario), - units: "per home", + scenario: overlay?.avgBills && formatNumber(overlay.avgBills.scenario), + units: "/ home", + baselineTotal: totals.total_bills ?? 0, + scenarioTotal: overlay?.avgBills?.scenarioTotal, subtitle: "Estimated annual energy bills.", - totalValue: `Total: £${formatNumber(totals.total_bills ?? 0)}`, delta: - scenarioOverlay?.avgBills && - deltaLabel( - scenarioOverlay.avgBills.baseline, - scenarioOverlay.avgBills.scenario - ), + hasScenario && overlay?.avgBills + ? deltaLabel(overlay.avgBills.baseline, overlay.avgBills.scenario) + : null, }, ]; @@ -133,8 +141,6 @@ export function DashboardSummaryCards({ const Icon = cardStyles[c.key as MetricKey].icon; const color = cardStyles[c.key as MetricKey].color; - console.log("Card data:", c); - return ( - {/* BASELINE */} + {/* BASELINE COLUMN */}
Baseline - - {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} - - {c.units && ( - {c.units} + +
+ + {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} + + + {/* units next to baseline average */} + {c.units && ( + {c.units} + )} +
+ + {/* Baseline total */} + {c.baselineTotal !== undefined && ( + + Total:{" "} + {c.key === "avgBills" + ? `£${formatNumber(c.baselineTotal)}` + : `${formatNumber(c.baselineTotal)} tCO₂e`} + )}
- {/* SCENARIO */} + {/* SCENARIO COLUMN */} {hasScenario && c.scenario && (
Scenario - - {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} - + {/* average + delta + units row */} +
+ + {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} + - {c.delta &&
{c.delta}
} + {c.delta && {c.delta}} +
+ + {/* Scenario total */} + {c.scenarioTotal !== undefined && ( + + Total:{" "} + {c.key === "avgBills" + ? `£${formatNumber(c.scenarioTotal)}` + : `${formatNumber(c.scenarioTotal)} tCO₂e`} + + )}
)} - {/* TOTAlS */} - {c.totalValue && ( -
{c.totalValue}
- )} - {/* Missing EPC bar */} {c.key === "missingEpc" && (
diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index f8f1b40..532bd91 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper"; import { DashboardSummaryCards } from "./DashboardSummaryCards"; import { BreakdownChart } from "./BreakdownChart"; @@ -27,6 +28,26 @@ interface ReportingClientAreaProps { portfolioId: number; } +// ---------------------------------------- +// Fetcher for scenario API route +// ---------------------------------------- +async function fetchScenarioReport({ + portfolioId, + scenarioId, +}: { + portfolioId: number; + scenarioId: number; +}) { + const res = await fetch( + `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics` + ); + if (!res.ok) { + console.error("Failed to fetch scenario report:", await res.text()); + throw new Error("Failed to load scenario report"); + } + return res.json(); +} + export function ReportingClientArea({ baseline, propertyTypes, @@ -37,74 +58,78 @@ export function ReportingClientArea({ null ); - const [scenarioMetrics, setScenarioMetrics] = useState(null); const drawerOpen = Boolean(selectedScenarioId); - // 🔥 Hardcoded scenario metrics (replace later with real fetch) - useEffect(() => { - if (!selectedScenarioId) { - setScenarioMetrics(null); - return; - } + // ---------------------------------------- + // React Query: fetch scenario metrics + // ---------------------------------------- + const { + data: scenarioData, + isLoading, + isError, + } = useQuery({ + queryKey: ["scenario-report", portfolioId, selectedScenarioId], + queryFn: () => + fetchScenarioReport({ + portfolioId, + scenarioId: selectedScenarioId!, + }), + enabled: !!selectedScenarioId, // only run when scenario selected + }); - const mocked = { - averages: { - avg_sap: 82, - avg_carbon: 1.7, - avg_bills: 1300, - }, - totals: { - total_carbon: 1.7 * 120, - total_bills: 1300 * 120, - }, - valuation: { - baseline: 130_000_000, - scenario: 136_000_000, - }, - }; - console.log("Setting mocked scenario metrics"); - setScenarioMetrics(mocked); - }, [selectedScenarioId]); - - // 👇 Active metrics switch to scenario if present - // Baseline always stays baseline - const activeMetrics = baseline; - - // Scenario overlay stays separate - const scenarioOverlay = scenarioMetrics + // ---------------------------------------- + // Build overlay for Dashboard Summary cards + // ---------------------------------------- + const scenarioOverlay = scenarioData ? { avgSap: { - baseline: baseline.averages.avg_sap || 0, - scenario: scenarioMetrics.averages.avg_sap, + baseline: baseline.averages.avg_sap ?? 0, + scenario: Number(scenarioData.avg_sap), }, avgCarbon: { - baseline: baseline.averages.avg_carbon || 0, - scenario: scenarioMetrics.averages.avg_carbon, + baseline: Number(baseline.averages.avg_carbon ?? 0), + scenario: Number(scenarioData.avg_carbon), + + baselineTotal: Number(baseline.totals.total_carbon ?? 0), + scenarioTotal: Number(scenarioData.total_carbon ?? 0), }, avgBills: { - baseline: baseline.averages.avg_bills || 0, - scenario: scenarioMetrics.averages.avg_bills, + baseline: baseline.averages.avg_bills ?? 0, + scenario: scenarioData.avg_bills, + baselineTotal: baseline.totals.total_bills ?? 0, + scenarioTotal: scenarioData.total_bills, }, - valuation: scenarioMetrics.valuation, + valuation: { baseline: null, scenario: null }, } : null; - // -------------------- - // Scenario Financial Metrics (mocked) - // -------------------- - const scenarioFinancial = scenarioMetrics + console.log("Scenario Data:", scenarioData); + + // ---------------------------------------- + // Financial drawer values (from API) + // ---------------------------------------- + const scenarioFinancial = scenarioData ? { - totalCost: 5_400_000, - contingency: 540_000, - funding: 2_100_000, - costPerSap: 3200, - costPerCo2: 180, - netCost: 5_400_000 - 2_100_000, - netCostPerUnit: (5_400_000 - 2_100_000) / 120, - nUnits: 120, + totalCost: scenarioData.total_cost, + contingency: scenarioData.contingency, + funding: scenarioData.total_funding, + costPerSap: + scenarioData.total_cost > 0 + ? scenarioData.total_cost / scenarioData.avg_sap + : 0, + costPerCo2: + scenarioData.total_cost > 0 + ? scenarioData.total_cost / scenarioData.total_carbon + : 0, + netCost: scenarioData.net_cost, + netCostPerUnit: scenarioData.net_cost_per_unit, + nUnits: scenarioData.n_units, } : null; + // Baseline stays baseline + const activeMetrics = baseline; + return ( <> + {/* LOADING + ERROR STATES */} + {isLoading && selectedScenarioId && ( +
Loading scenario…
+ )} + {isError && ( +
+ Couldn't load scenario data. +
+ )} + {/* --- RETROFIT SECTION --- */}