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 e4f4157..ce373b6 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -2,8 +2,13 @@ import { db } from "@/app/db/db"; import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { sapToEpc } from "@/app/utils"; +import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; -type BaselineAggregates = { +/* ======================= + Types +======================= */ + +type ScenarioAggregates = { n_units: number; avg_sap: number | null; avg_carbon: number | null; @@ -11,7 +16,6 @@ type BaselineAggregates = { total_carbon: number | null; total_bills: number | null; total_sap_uplift: number | null; - sap_points_array: (number | null)[]; }; type UpgradedAggregates = { @@ -21,120 +25,243 @@ type UpgradedAggregates = { total_funding: number | null; }; +type PortfolioAggregates = { + avg_sap: number | null; + avg_carbon: number | null; + avg_bills: number | null; + total_carbon: number | null; + total_bills: number | null; +}; + +type EpcRow = { + effective_sap: number | null; +}; + +/* ======================= + Constants +======================= */ + +const EPC_MIN_SAP: Record = { + A: 92, + B: 81, + C: 69, + D: 55, + E: 39, + F: 21, + G: 0, +}; + +/* ======================= + Route +======================= */ + export async function GET( request: NextRequest, - props: { params: Promise<{ portfolioId: string; scenarioId: string }> } + props: { params: Promise<{ portfolioId: string; scenarioId: string }> }, ) { const { portfolioId, scenarioId } = await props.params; - const pid = BigInt(portfolioId); - const sid = BigInt(scenarioId); - // - // ---------------------------------------------------------- - // QUERY 1 — Baseline metrics for *all* properties - // ---------------------------------------------------------- - // - const baselineResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - ORDER BY property_id, created_at DESC -) - -SELECT - COUNT(*)::int AS n_units, - - AVG(lp.post_sap_points)::float AS avg_sap, - AVG(lp.post_co2_emissions)::float AS avg_carbon, - AVG(lp.post_energy_bill)::float AS avg_bills, - - SUM(lp.post_co2_emissions)::float AS total_carbon, - SUM(lp.post_energy_bill)::float AS total_bills, - - SUM( - CASE - WHEN lp.cost_of_works > 0.01 - AND p.current_sap_points IS NOT NULL - AND lp.post_sap_points IS NOT NULL - THEN lp.post_sap_points - p.current_sap_points - ELSE 0 - END - )::float AS total_sap_uplift, - - ARRAY_AGG(lp.post_sap_points) AS sap_points_array - -FROM latest_plans lp -JOIN property p - ON p.id = lp.property_id; -`); - - const baseline = baselineResult.rows[0] as BaselineAggregates | undefined; - - if (!baseline || baseline.n_units === 0) { - return NextResponse.json( - { error: "No plans found for this scenario" }, - { status: 404 } - ); + if (!scenarioId || scenarioId === "null") { + return NextResponse.json({ error: "Invalid scenarioId" }, { status: 400 }); } - const { - n_units, - avg_sap, - avg_carbon, - avg_bills, - total_carbon, - total_bills, - sap_points_array, - } = baseline; + const pid = BigInt(portfolioId); + const sid = BigInt(scenarioId); + const hideNonCompliant = + request.nextUrl.searchParams.get("hideNonCompliant") === "true"; - // - // ---------------------------------------------------------- - // QUERY 2 — Upgrade metrics for properties receiving work - // ---------------------------------------------------------- - // + /* ---------------------------------------------------------- + Query 0 — scenario definition + ---------------------------------------------------------- */ + const scenarioResult = await db.execute(sql` + SELECT goal, goal_value + FROM scenario + WHERE id = ${sid} + AND portfolio_id = ${pid} + LIMIT 1 + `); + + const scenario = scenarioResult.rows[0] as + | { goal: PortfolioGoalType; goal_value: string } + | undefined; + + if (!scenario) { + return NextResponse.json({ error: "Scenario not found" }, { status: 404 }); + } + + const minSap = + scenario.goal === "Increasing EPC" + ? EPC_MIN_SAP[scenario.goal_value] + : null; + + /* ---------------------------------------------------------- + QUERY 1 — Scenario metrics (PLANS ONLY) + ---------------------------------------------------------- */ + const scenarioMetricsResult = await db.execute(sql` + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND post_sap_points >= ${minSap}::float + ) + ) + ORDER BY property_id, created_at DESC + ) + SELECT + 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, + SUM( + CASE + WHEN cost_of_works > 0 + AND post_sap_points IS NOT NULL + THEN post_sap_points - p.current_sap_points + ELSE 0 + END + )::float AS total_sap_uplift + FROM latest_plans lp + JOIN property p ON p.id = lp.property_id; + `); + + const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; + + /* ---------------------------------------------------------- + QUERY 1b — Upgrade costs (PLANS ONLY) + ---------------------------------------------------------- */ const upgradedResult = await db.execute(sql` - WITH latest_plans AS ( - SELECT DISTINCT ON (property_id) - * - FROM plan - WHERE portfolio_id = ${pid} - AND scenario_id = ${sid} - ORDER BY property_id, created_at DESC - ) - - 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 latest_plans lp - LEFT JOIN funding_package fp - ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0.01; -`); + WITH latest_plans AS ( + SELECT DISTINCT ON (property_id) + * + FROM plan + WHERE portfolio_id = ${pid} + AND scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND post_sap_points >= ${minSap}::float + ) + ) + ORDER BY property_id, created_at DESC + ) + 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 latest_plans lp + LEFT JOIN funding_package fp ON fp.plan_id = lp.id + WHERE lp.cost_of_works > 0; + `); const upgraded = upgradedResult.rows[0] as UpgradedAggregates; - const n_units_upgraded = upgraded.n_units_upgraded; - const construction_cost = upgraded.total_cost ?? 0; - const contingency = upgraded.contingency ?? 0; - const total_funding = upgraded.total_funding ?? 0; - const net_cost = construction_cost - total_funding; - const pc_cost = construction_cost * 0.3; // Placeholder for PC cost - const total_sap_uplift = baseline.total_sap_uplift ?? 0; + /* ---------------------------------------------------------- + QUERY 2 — Portfolio AFTER scenario (ALL properties) + ---------------------------------------------------------- */ + const portfolioMetricsResult = await db.execute(sql` + SELECT + AVG(effective_sap)::float AS avg_sap, + AVG(effective_carbon)::float AS avg_carbon, + AVG(effective_bills)::float AS avg_bills, + SUM(effective_carbon)::float AS total_carbon, + SUM(effective_bills)::float AS total_bills + FROM ( + SELECT + /* ---------- SAP ---------- */ + CASE + WHEN lp.id IS NOT NULL THEN lp.post_sap_points + ELSE p.current_sap_points + END AS effective_sap, + + /* ---------- Carbon ---------- */ + CASE + WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions + ELSE e.co2_emissions + END AS effective_carbon, + + /* ---------- Bills ---------- */ + CASE + WHEN lp.id IS NOT NULL THEN lp.post_energy_bill + ELSE ( + e.heating_cost_current + + e.hot_water_cost_current + + e.lighting_cost_current + + e.appliances_cost_current + + e.gas_standing_charge + + e.electricity_standing_charge - + COALESCE(e.installed_measures_total_energy_bill_adjustment, 0) + ) + END AS effective_bills + + FROM property p + LEFT JOIN property_details_epc e + ON e.property_id = p.id + + LEFT JOIN LATERAL ( + SELECT * + FROM plan + WHERE plan.property_id = p.id + AND plan.portfolio_id = ${pid} + AND plan.scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND plan.post_sap_points >= ${minSap}::float + ) + ) + ORDER BY created_at DESC + LIMIT 1 + ) lp ON true + + WHERE p.portfolio_id = ${pid} + ) q; + `); + + const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates; + + /* ---------------------------------------------------------- + QUERY 3 — EPC band distribution (ALL properties) + ---------------------------------------------------------- */ + const epcRows = await db.execute(sql` + SELECT + CASE + WHEN lp.id IS NOT NULL THEN lp.post_sap_points + ELSE p.current_sap_points + END AS effective_sap + FROM property p + LEFT JOIN LATERAL ( + SELECT * + FROM plan + WHERE plan.property_id = p.id + AND plan.portfolio_id = ${pid} + AND plan.scenario_id = ${sid} + AND ( + ${hideNonCompliant} = false + OR ( + ${minSap}::float IS NOT NULL + AND plan.post_sap_points >= ${minSap}::float + ) + ) + ORDER BY created_at DESC + LIMIT 1 + ) lp ON true + WHERE p.portfolio_id = ${pid}; + `); - // - // ---------------------------------------------------------- - // EPC band distribution (all properties) - // ---------------------------------------------------------- - // const scenario_epc_counts: Record = { A: 0, B: 0, @@ -146,36 +273,43 @@ JOIN property p Unknown: 0, }; - for (const sap of sap_points_array) { - const band = sapToEpc(sap); + for (const row of epcRows.rows as EpcRow[]) { + const band = sapToEpc(row.effective_sap); scenario_epc_counts[band] += 1; } - // - // ---------------------------------------------------------- - // RESPONSE - // ---------------------------------------------------------- - // + /* ---------------------------------------------------------- + RESPONSE + ---------------------------------------------------------- */ + + const constructionCost = upgraded.total_cost ?? 0; + const nUpgraded = upgraded.n_units_upgraded ?? 0; + const pc_cost = constructionCost * 0.3; + return NextResponse.json({ - // Baseline metrics (all units) - avg_sap: avg_sap !== null ? Number(avg_sap).toFixed(1) : null, - avg_carbon, - avg_bills, - total_carbon, - total_bills, - n_units, + /* -------- portfolio-after-scenario -------- */ + avg_sap: + portfolioAgg.avg_sap !== null + ? Number(portfolioAgg.avg_sap).toFixed(1) + : null, + avg_carbon: portfolioAgg.avg_carbon, + avg_bills: portfolioAgg.avg_bills, + total_carbon: portfolioAgg.total_carbon, + total_bills: portfolioAgg.total_bills, + + /* -------- scenario-only -------- */ + n_units: scenarioAgg.n_units, + n_units_upgraded: nUpgraded, + construction_cost: constructionCost, + contingency: upgraded.contingency ?? 0, + total_funding: upgraded.total_funding ?? 0, + net_cost: constructionCost - (upgraded.total_funding ?? 0), + total_sap_uplift: scenarioAgg.total_sap_uplift ?? 0, + gross_per_unit: + nUpgraded > 0 ? (constructionCost + pc_cost) / nUpgraded : 0, + + /* -------- shared -------- */ scenario_epc_counts, pc_cost, - // Upgrade metrics (only properties with work) - n_units_upgraded, - construction_cost, - contingency, - total_funding, - net_cost, - gross_per_unit: - n_units_upgraded > 0 - ? (construction_cost + pc_cost) / n_units_upgraded - : 0, - total_sap_uplift, }); } diff --git a/src/app/api/portfolio/scenario/[scenarioId]/route.ts b/src/app/api/portfolio/scenario/[scenarioId]/route.ts index 085b5f4..710163c 100644 --- a/src/app/api/portfolio/scenario/[scenarioId]/route.ts +++ b/src/app/api/portfolio/scenario/[scenarioId]/route.ts @@ -6,7 +6,10 @@ import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils"; import { eq } from "drizzle-orm"; import { scenario } from "@/app/db/schema/recommendations"; -export async function GET(request: NextRequest, props: { params: Promise<{ scenarioId: string }> }) { +export async function GET( + request: NextRequest, + props: { params: Promise<{ scenarioId: string }> }, +) { const params = await props.params; const scenarioId = params.scenarioId; @@ -50,7 +53,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena { scenarioName: scenarioName, data: JSON.parse( - data[0].epcBreakdownPostRetrofit || "[]" + data[0].epcBreakdownPostRetrofit || "[]", ) as ChartData[], }, ], @@ -114,29 +117,49 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena scenarios: [ { scenarioName: scenarioName, data: data[0].costPerUnit || "" }, ], - }, + }, { title: "Funding (£)", scenarios: [ - { scenarioName: scenarioName, data: "£" + formatNumber(data[0].funding || 0) }, + { + scenarioName: scenarioName, + data: "£" + formatNumber(data[0].funding || 0), + }, ], }, { title: "Funding (£)/unit", scenarios: [ - { scenarioName: scenarioName, data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)) }, + { + scenarioName: scenarioName, + data: + "£" + + formatNumber( + (data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1), + ), + }, ], }, { title: "Contingency (£)", scenarios: [ - { scenarioName: scenarioName, data: "£" + formatNumber(data[0].contingency || 0) }, + { + scenarioName: scenarioName, + data: "£" + formatNumber(data[0].contingency || 0), + }, ], }, { title: "Contingency (£)/unit", scenarios: [ - { scenarioName: scenarioName, data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)) }, + { + scenarioName: scenarioName, + data: + "£" + + formatNumber( + (data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1), + ), + }, ], }, { diff --git a/src/app/db/schema/portfolio.ts b/src/app/db/schema/portfolio.ts index 6cf833e..d424634 100644 --- a/src/app/db/schema/portfolio.ts +++ b/src/app/db/schema/portfolio.ts @@ -31,6 +31,14 @@ export const PortfolioGoal: [string, ...string[]] = [ "Energy Savings", "None", ]; +export type PortfolioGoalType = (typeof PortfolioGoal)[number]; +export const PORTFOLIO_GOALS = { + EPC: "Increasing EPC", + VALUATION: "Valuation Improvement", + CO2: "Reducing CO2 emissions", + ENERGY: "Energy Savings", + NONE: "None", +} satisfies Record; export const PortfolioRole: [string, ...string[]] = [ "creator", @@ -79,10 +87,10 @@ export const portfolio = pgTable("portfolio", { energyBillPerUnitPreRetrofit: text("energy_bill_per_unit_pre_retrofit"), energyBillPerUnitPostRetrofit: text("energy_bill_per_unit_post_retrofit"), energyConsumptionPerUnitPreRetrofit: text( - "energy_consumption_per_unit_pre_retrofit" + "energy_consumption_per_unit_pre_retrofit", ), energyConsumptionPerUnitPostRetrofit: text( - "energy_consumption_per_unit_post_retrofit" + "energy_consumption_per_unit_post_retrofit", ), valuationImprovementPerUnit: text("valuation_improvement_per_unit"), costPerUnit: text("cost_per_unit"), diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx index 425b1ee..439b54a 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx @@ -9,23 +9,29 @@ import { } from "@/app/shadcn_components/ui/card"; import { motion } from "framer-motion"; import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react"; -import { formatNumber } from "@/app/utils"; +import { formatNumber, sapToEpc } from "@/app/utils"; import type { AverageMetrics, EstimatedCounts, TotalMetrics, ScenarioOverlayMetrics, + MetricKey, } from "./types"; -import type { MetricKey } from "./types"; -import { sapToEpc } from "@/app/utils"; -const cardStyles = { +/* ───────────────────────────────────────────── */ +/* Style maps */ +/* ───────────────────────────────────────────── */ + +const cardStyles: Record< + MetricKey, + { icon: React.ComponentType; color: string } +> = { totalHomes: { icon: Home, color: "text-purple-600" }, avgSap: { icon: LineChart, color: "text-blue-600" }, avgCarbon: { icon: Leaf, color: "text-emerald-600" }, avgBills: { icon: Zap, color: "text-amber-600" }, missingEpc: { icon: FileQuestionIcon, color: "text-red-600" }, -} as Record; color: string }>; +}; const epcColors: Record = { A: "text-epc_a", @@ -38,24 +44,38 @@ const epcColors: Record = { Unknown: "text-gray-400", }; +/* ───────────────────────────────────────────── */ +/* Helpers */ +/* ───────────────────────────────────────────── */ + function hasOverlay( - overlay: ScenarioOverlayMetrics | undefined + overlay: ScenarioOverlayMetrics | undefined, ): overlay is ScenarioOverlayMetrics { return overlay !== undefined; } +function Skeleton({ className = "" }: { className?: string }) { + return
; +} + +/* ───────────────────────────────────────────── */ +/* Component */ +/* ───────────────────────────────────────────── */ + export function DashboardSummaryCards({ total, totals, averages, estimatedCounts, scenarioOverlay, + loading = false, }: { total: number; totals: TotalMetrics; averages: AverageMetrics; estimatedCounts: EstimatedCounts; scenarioOverlay?: ScenarioOverlayMetrics | null; + loading?: boolean; }) { const missingEpcCount = estimatedCounts.estimated; const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0; @@ -66,10 +86,7 @@ export function DashboardSummaryCards({ const hasScenario = hasOverlay(overlay); function deltaLabel(baseline: number, scenario: number) { - const b = Number(baseline); - const s = Number(scenario); - const diff = s - b; - + const diff = scenario - baseline; if (!isFinite(diff) || diff === 0) return null; const sign = diff > 0 ? "▲" : "▼"; @@ -87,10 +104,6 @@ export function DashboardSummaryCards({ key: "totalHomes", title: "Number of Homes", baseline: total, - scenario: null, - baselineTotal: undefined, - scenarioTotal: undefined, - units: "", subtitle: "Total properties in this portfolio.", }, { @@ -100,8 +113,6 @@ export function DashboardSummaryCards({ scenario: overlay?.avgSap && `${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`, - baselineTotal: undefined, - scenarioTotal: undefined, subtitle: "Current SAP rating across all properties.", isEpc: true, }, @@ -144,92 +155,110 @@ export function DashboardSummaryCards({ return ( + {/* Header */} - - - - - {c.title} - + {loading ? ( + <> + + + + ) : ( + <> + + + + + {c.title} + + + )} + {/* Content */} - {/* BASELINE + SCENARIO ROW */}
- {/* BASELINE COLUMN */} + {/* Baseline */}
Baseline -
- - {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`} - + {loading ? ( + + ) : ( +
+ + {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} + + {c.units && ( + {c.units} + )} +
)} + + {c.baselineTotal !== undefined && + (loading ? ( + + ) : ( + + Total:{" "} + {c.key === "avgBills" + ? `£${formatNumber(c.baselineTotal)}` + : `${formatNumber(c.baselineTotal)} tCO₂e`} + + ))}
- {/* SCENARIO COLUMN */} + {/* Scenario */} {hasScenario && c.scenario && (
Scenario - {/* average + delta + units row */} -
- - {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} - - - {c.delta && {c.delta}} -
- - {/* Scenario total */} - {c.scenarioTotal !== undefined && ( - - Total:{" "} - {c.key === "avgBills" - ? `£${formatNumber(c.scenarioTotal)}` - : `${formatNumber(c.scenarioTotal)} tCO₂e`} - + {loading ? ( + + ) : ( +
+ + {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} + + {c.delta && {c.delta}} +
)} + + {c.scenarioTotal !== undefined && + (loading ? ( + + ) : ( + + Total:{" "} + {c.key === "avgBills" + ? `£${formatNumber(c.scenarioTotal)}` + : `${formatNumber(c.scenarioTotal)} tCO₂e`} + + ))}
)}
@@ -246,7 +275,11 @@ export function DashboardSummaryCards({
-

{c.subtitle}

+ {loading ? ( + + ) : ( +

{c.subtitle}

+ )}
); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 509493c..7026af4 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper"; import { DashboardSummaryCards } from "./DashboardSummaryCards"; @@ -21,6 +21,7 @@ import type { PropertyTypeCount, ScenarioSummary, } from "./types"; +import { ReportingFunctionalityButtons } from "./ReportingFunctionalityButtons"; interface ReportingClientAreaProps { baseline: BaselineMetrics; @@ -35,12 +36,17 @@ interface ReportingClientAreaProps { async function fetchScenarioReport({ portfolioId, scenarioId, + hideNonCompliant, }: { portfolioId: number; scenarioId: number; + hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/ }) { + const params = new URLSearchParams({ + hideNonCompliant: String(hideNonCompliant), + }); const res = await fetch( - `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics` + `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`, ); if (!res.ok) { console.error("Failed to fetch scenario report:", await res.text()); @@ -57,7 +63,7 @@ async function fetchScenarioMeasures({ scenarioId: number; }) { const res = await fetch( - `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures` + `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`, ); if (!res.ok) { @@ -74,9 +80,11 @@ export function ReportingClientArea({ portfolioId, }: ReportingClientAreaProps) { const [selectedScenarioId, setSelectedScenarioId] = useState( - null + null, ); const [measuresOpen, setMeasuresOpen] = useState(false); + const [appliedHideNonCompliant, setAppliedHideNonCompliant] = + useState(false); const drawerOpen = Boolean(selectedScenarioId); @@ -86,15 +94,24 @@ export function ReportingClientArea({ const { data: scenarioData, isLoading, + isFetching, isError, } = useQuery({ - queryKey: ["scenario-report", portfolioId, selectedScenarioId], + queryKey: [ + "scenario-report", + portfolioId, + selectedScenarioId, + appliedHideNonCompliant, + ], queryFn: () => fetchScenarioReport({ portfolioId, scenarioId: selectedScenarioId!, + hideNonCompliant: appliedHideNonCompliant, }), enabled: !!selectedScenarioId, // only run when scenario selected + keepPreviousData: true, // keep showing old data while loading new scenario or applying filter + refetchOnWindowFocus: false, }); const { @@ -109,10 +126,10 @@ export function ReportingClientArea({ scenarioId: selectedScenarioId!, }), enabled: measuresOpen && !!selectedScenarioId, + keepPreviousData: true, + refetchOnWindowFocus: false, }); - const scenarioLoading = isLoading && !!selectedScenarioId; - // ---------------------------------------- // Build overlay for Dashboard Summary cards // ---------------------------------------- @@ -145,40 +162,45 @@ export function ReportingClientArea({ // Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline // ---------------------------------------- - const scenarioSpecific = scenarioData - ? { - constructionCost: scenarioData.construction_cost, - pcCost: scenarioData.pc_cost, - contingency: scenarioData.contingency, - funding: scenarioData.total_funding, - costPerSap: - scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - scenarioData.total_sap_uplift - : 0, - costPerCo2: - scenarioData.construction_cost > 0 - ? (scenarioData.construction_cost + scenarioData.pc_cost) / - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) - : 0, - netCost: scenarioData.net_cost, - grossPerUnit: scenarioData.gross_per_unit, - nUnits: scenarioData.n_units_upgraded, - totalCarbonSaved: - (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, - totalBillsSaved: - (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, - averageCaribonSaved: - ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / - scenarioData.n_units_upgraded, - averageBillsSaved: - ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / - scenarioData.n_units_upgraded, - } - : null; + const scenarioSpecific = useMemo(() => { + if (!scenarioData) return null; + + return { + constructionCost: scenarioData.construction_cost, + pcCost: scenarioData.pc_cost, + contingency: scenarioData.contingency, + funding: scenarioData.total_funding, + costPerSap: + scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0 + ? (scenarioData.construction_cost + scenarioData.pc_cost) / + scenarioData.total_sap_uplift + : 0, + costPerCo2: + scenarioData.construction_cost > 0 + ? (scenarioData.construction_cost + scenarioData.pc_cost) / + ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) + : 0, + netCost: scenarioData.net_cost, + grossPerUnit: scenarioData.gross_per_unit, + nUnits: scenarioData.n_units_upgraded, + totalCarbonSaved: + (baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon, + totalBillsSaved: + (baseline.totals.total_bills ?? 0) - scenarioData.total_bills, + averageCaribonSaved: + ((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) / + scenarioData.n_units_upgraded, + averageBillsSaved: + ((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) / + scenarioData.n_units_upgraded, + }; + }, [scenarioData, baseline]); + // Baseline stays baseline const activeMetrics = baseline; + const scenarioBusy = !!selectedScenarioId && (isLoading || isFetching); + return ( <>
@@ -193,33 +215,43 @@ export function ReportingClientArea({ {/* RIGHT: Actions (only when scenario selected) */} {selectedScenarioId && (
+ {/* Show measures */} + { + setAppliedHideNonCompliant(value); + }} + /> + + {/* Download PDF */} + + + +
+ {/* Filter option */} +
+ + setDraftHideNonCompliant(Boolean(checked)) + } + className="mt-1" + /> + + +
+ + {/* Actions */} +
+ + + +
+
+
+ + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx index ccdf266..2a614da 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ScenarioFinancialDrawer.tsx @@ -26,6 +26,7 @@ import { Gauge } from "lucide-react"; interface ScenarioFinancialDrawerProps { open: boolean; metrics: any | null; + loading?: boolean; } /* ───────────────────────────────────────────── */ @@ -56,7 +57,7 @@ function GradientCard({ className={clsx( "relative rounded-lg p-[2px] gradient-card", gradient, - `gradient-${variant}` + `gradient-${variant}`, )} >
{children}
@@ -75,6 +76,7 @@ function Metric({ color, gradient, variant = "green", + loading = false, }: { label: string; value: string | number; @@ -82,15 +84,38 @@ function Metric({ color: string; gradient: string; variant?: "green" | "blue" | "purple"; + loading?: boolean; }) { + if (loading || !value) { + return ( + +
+
+
+
+ + ); + } return (
- - {value} - - {label} - + {loading ? ( +
+
+
+
+
+ ) : ( + <> + + + {value} + + + {label} + + + )}
); @@ -108,6 +133,7 @@ function PairedMetric({ gradient, iconClassName = "text-gray-700", variant = "green", + loading = false, }: { title: string; icon: React.ComponentType>; @@ -116,30 +142,62 @@ function PairedMetric({ gradient: string; iconClassName?: string; variant?: "green" | "blue" | "purple"; + loading?: boolean; }) { + if (loading || !primary.value || !secondary.value) { + return ( + +
+
+
+
+ + ); + } + return (
-
- - {title} -
- -
-
-

{primary.label}

-

- {primary.value} -

+ {loading ? ( +
+
+
+
+
+
+
+
+
+
+
+
+ ) : ( + <> +
+ + + {title} + +
-
-

{secondary.label}

-

- {secondary.value} -

-
-
+
+
+

{primary.label}

+

+ {primary.value} +

+
+ +
+

{secondary.label}

+

+ {secondary.value} +

+
+
+ + )}
); @@ -172,7 +230,7 @@ function Section({
@@ -191,6 +249,22 @@ function Section({ ); } +/* ───────────────────────────────────────────── */ +/* Loading Skeleton for dashboard cards */ +/* ───────────────────────────────────────────── */ + +function LoadingOverlay() { + return ( +
+
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ ))} +
+
+ ); +} + /* ───────────────────────────────────────────── */ /* Main Drawer */ /* ───────────────────────────────────────────── */ @@ -198,10 +272,11 @@ function Section({ export function ScenarioFinancialDrawer({ open, metrics, + loading = false, }: ScenarioFinancialDrawerProps) { return ( - {open && metrics && ( + {open && ( @@ -278,32 +362,37 @@ export function ScenarioFinancialDrawer({ iconClassName="text-blue-600" primary={{ label: "Construction works", - value: `£${formatNumber(metrics.constructionCost)}`, + value: metrics + ? `£${formatNumber(metrics.constructionCost)}` + : "", }} secondary={{ label: "Project delivery", - value: `£${formatNumber(metrics.pcCost)}`, + value: metrics ? `£${formatNumber(metrics.pcCost)}` : "", }} gradient={gradients.blue} variant="blue" + loading={loading} /> @@ -321,14 +410,15 @@ export function ScenarioFinancialDrawer({ iconClassName="text-purple-700" primary={{ label: "£ per SAP point", - value: `£${formatNumber(metrics.costPerSap)}`, + value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "", }} secondary={{ label: "£ per tonne CO₂", - value: `£${formatNumber(metrics.costPerCo2)}`, + value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "", }} gradient={gradients.purple} variant="purple" + loading={loading} />
diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index 25e703a..7dbf121 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -23,7 +23,7 @@ export async function getPortfolioCounts(portfolioId: number): Promise { } export async function getAverages( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT @@ -69,7 +69,7 @@ export async function getTotals(portfolioId: number): Promise { } export async function getCountByAgeBand( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT @@ -96,23 +96,27 @@ export async function getCountByAgeBand( } export async function getCountByEpcBand( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT * FROM ( - SELECT + SELECT COALESCE(p.current_epc_rating::text, 'Unknown') AS epc, - SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual, - SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated + COUNT(*) FILTER ( + WHERE e.estimated = false OR e.estimated IS NULL + )::int AS actual, + COUNT(*) FILTER ( + WHERE e.estimated = true + )::int AS estimated FROM property p - LEFT JOIN property_details_epc e + LEFT JOIN property_details_epc e ON e.property_id = p.id WHERE p.portfolio_id = ${portfolioId} GROUP BY epc ) q - ORDER BY - CASE + ORDER BY + CASE WHEN q.epc = 'A' THEN 1 WHEN q.epc = 'B' THEN 2 WHEN q.epc = 'C' THEN 3 @@ -120,7 +124,7 @@ export async function getCountByEpcBand( WHEN q.epc = 'E' THEN 5 WHEN q.epc = 'F' THEN 6 WHEN q.epc = 'G' THEN 7 - ELSE 8 -- 'Unknown' + ELSE 8 END; `); @@ -128,7 +132,7 @@ export async function getCountByEpcBand( } export async function getEstimatedCounts( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT @@ -142,7 +146,7 @@ export async function getEstimatedCounts( } export async function getCountByPropertyType( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute(sql` SELECT property_type AS type, COUNT(*)::int AS count @@ -173,7 +177,7 @@ export async function getExpiredEpcCount(portfolioId: number): Promise { } export async function getLikelyDowngrades( - portfolioId: number + portfolioId: number, ): Promise { const result = await db.execute<{ downgrades: number }>(sql` SELECT @@ -192,7 +196,7 @@ export async function getLikelyDowngrades( } export async function loadBaselineMetrics( - portfolioId: number + portfolioId: number, ): Promise { const [ total, diff --git a/src/middleware.ts b/src/middleware.ts index f1f44e9..4fa8c46 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) { export const config = { matcher: [ - // Protect only your app’s authenticated areas + // Protect only app’s authenticated areas "/home/:path*", "/portfolio/:path*", "/search/:path*",