Added scenario comparison ui - still missing cost

This commit is contained in:
Khalim Conn-Kowlessar 2025-12-05 23:49:24 +00:00
parent dd927da618
commit c5b2c80970
6 changed files with 315 additions and 130 deletions

View file

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

View file

@ -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"),

View file

@ -38,6 +38,12 @@ const epcColors: Record<string, string> = {
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 (
<span className={`text-sm font-medium ${color}`}>
{sign} {formatNumber(Math.abs(diff))}
{sign} {Math.abs(diff).toFixed(2)}
</span>
);
}
@ -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 (
<Card
key={c.key}
@ -156,55 +162,78 @@ export function DashboardSummaryCards({
hasScenario ? "justify-between" : "justify-start"
} items-start`}
>
{/* BASELINE */}
{/* BASELINE COLUMN */}
<div className="flex flex-col">
<span className="text-xs text-gray-500">Baseline</span>
<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 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>
{/* 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>
)}
</div>
{/* SCENARIO */}
{/* SCENARIO COLUMN */}
{hasScenario && c.scenario && (
<div className="flex flex-col text-right">
<span className="text-xs text-gray-500">Scenario</span>
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
scenarioOverlay?.avgSap?.scenario ??
(averages.avg_sap || 0)
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.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 && <div>{c.delta}</div>}
{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>
)}
</div>
)}
</div>
{/* TOTAlS */}
{c.totalValue && (
<div className="text-sm text-gray-700">{c.totalValue}</div>
)}
{/* Missing EPC bar */}
{c.key === "missingEpc" && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">

View file

@ -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<any>(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 (
<>
<ScenarioSelectorWrapper
@ -114,6 +139,16 @@ export function ReportingClientArea({
setSelectedScenarioId={setSelectedScenarioId}
/>
{/* LOADING + ERROR STATES */}
{isLoading && selectedScenarioId && (
<div className="text-sm text-gray-500 mt-2">Loading scenario</div>
)}
{isError && (
<div className="text-sm text-red-500 mt-2">
Couldn't load scenario data.
</div>
)}
{/* --- RETROFIT SECTION --- */}
<SectionDivider
title="Retrofit Summary"

View file

@ -3,15 +3,6 @@ import {
getCountByPropertyType,
getScenarios,
} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
import { DashboardSummaryCards } from "@/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards";
import { BreakdownChart } from "@/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart";
import { EpcQualityCards } from "@/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards";
import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider";
import {
PlaceholderMetricCards,
CONDITION_PLACEHOLDERS,
FINANCIAL_PLACEHOLDERS,
} from "@/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards";
import { ReportingClientArea } from "./ReportingClientArea";
export default async function ReportingPage(props: {

View file

@ -59,16 +59,23 @@ export interface ScenarioOverlayMetrics {
baseline: number;
scenario: number;
};
avgCarbon?: {
baseline: number;
scenario: number;
baselineTotal: number;
scenarioTotal: number;
};
avgBills?: {
baseline: number;
scenario: number;
baselineTotal: number;
scenarioTotal: number;
};
valuation?: {
baseline: number;
scenario: number;
baseline: number | null;
scenario: number | null;
};
}