diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/measures/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/measures/route.ts new file mode 100644 index 0000000..71819da --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/measures/route.ts @@ -0,0 +1,65 @@ +import { db } from "@/app/db/db"; +import { sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; + +type MeasureAggregateRow = { + measure_type: string | null; + type: string | null; + includes_battery: boolean | null; + homes_count: number; + total_cost: number | null; + average_cost: number | null; +}; + +export async function GET( + request: NextRequest, + props: { params: Promise<{ portfolioId: string; scenarioId: string }> }, +) { + const { portfolioId } = await props.params; + + const pid = BigInt(portfolioId); + + const result = await db.execute(sql` + SELECT + r.measure_type, + r.type, + COUNT(DISTINCT r.property_id)::int AS homes_count, + SUM(r.estimated_cost)::float AS total_cost, + AVG(r.estimated_cost)::float AS average_cost + FROM recommendation r + WHERE r.default = true + AND r.already_installed = false + AND EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT ON (p.property_id) + p.id + FROM plan p + WHERE p.portfolio_id = ${pid} + AND p.is_default = true + ORDER BY p.property_id, p.created_at DESC + ) lp + JOIN plan_recommendations pr + ON pr.plan_id = lp.id + WHERE pr.recommendation_id = r.id + ) + GROUP BY + r.measure_type, + r.type + ORDER BY total_cost DESC; + `); + + const measures = (result.rows as MeasureAggregateRow[]).map((row) => ({ + measureType: row.measure_type ?? "unknown", + type: row.type ?? "unknown", + homesCount: row.homes_count, + totalCost: Number(row.total_cost ?? 0), + averageCost: Number(row.average_cost ?? 0), + // includesBattery: row.includes_battery ?? false, + })); + + return NextResponse.json({ + portfolioId: Number(portfolioId), + measures, + }); +} diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts new file mode 100644 index 0000000..6d5df36 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts @@ -0,0 +1,262 @@ +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"; + +/* ======================= + Types +======================= */ + +type ScenarioAggregates = { + n_units: number; + avg_sap: number | null; + avg_carbon: number | null; + avg_bills: number | null; + total_carbon: number | null; + total_bills: number | null; + total_sap_uplift: number | null; +}; + +type UpgradedAggregates = { + n_units_upgraded: number; + total_cost: number | null; + contingency: number | null; + 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 }> }, +) { + const { portfolioId } = await props.params; + + if (!portfolioId || portfolioId === "null") { + return NextResponse.json({ error: "Invalid portfolioId" }, { status: 400 }); + } + + const pid = BigInt(portfolioId); + const hideNonCompliant = + request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + + /* ---------------------------------------------------------- + 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 is_default = true + 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 is_default = true + 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; + + /* ---------------------------------------------------------- + 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.is_default = true + 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.is_default = true + ORDER BY created_at DESC + LIMIT 1 + ) lp ON true + WHERE p.portfolio_id = ${pid}; + `); + + const scenario_epc_counts: Record = { + A: 0, + B: 0, + C: 0, + D: 0, + E: 0, + F: 0, + G: 0, + Unknown: 0, + }; + + for (const row of epcRows.rows as EpcRow[]) { + const band = sapToEpc(row.effective_sap); + scenario_epc_counts[band] += 1; + } + + /* ---------------------------------------------------------- + RESPONSE + ---------------------------------------------------------- */ + + const constructionCost = upgraded.total_cost ?? 0; + const nUpgraded = upgraded.n_units_upgraded ?? 0; + const pc_cost = constructionCost * 0.3; + + return NextResponse.json({ + /* -------- 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, + }); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 237a561..75ba4f3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -39,19 +39,24 @@ async function fetchScenarioReport({ hideNonCompliant, }: { portfolioId: number; - scenarioId: number; - hideNonCompliant: boolean; /* this will remove plans that do not meet upgrade targets*/ + scenarioId: number | "default"; + hideNonCompliant: boolean; }) { const params = new URLSearchParams({ hideNonCompliant: String(hideNonCompliant), }); - const res = await fetch( - `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics?${params.toString()}`, - ); + + const path = + scenarioId === "default" + ? `/api/portfolio/${portfolioId}/scenario/default/metrics` + : `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`; + + const res = await fetch(`${path}?${params.toString()}`); + if (!res.ok) { - console.error("Failed to fetch scenario report:", await res.text()); throw new Error("Failed to load scenario report"); } + return res.json(); } @@ -60,11 +65,14 @@ async function fetchScenarioMeasures({ scenarioId, }: { portfolioId: number; - scenarioId: number; + scenarioId: number | "default"; }) { - const res = await fetch( - `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`, - ); + const path = + scenarioId === "default" + ? `/api/portfolio/${portfolioId}/scenario/default/measures` + : `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`; + + const res = await fetch(path); if (!res.ok) { throw new Error("Failed to load measures"); @@ -79,9 +87,9 @@ export function ReportingClientArea({ scenarios, portfolioId, }: ReportingClientAreaProps) { - const [selectedScenarioId, setSelectedScenarioId] = useState( - null, - ); + const [selectedScenarioId, setSelectedScenarioId] = useState< + number | "default" | null + >(null); const [measuresOpen, setMeasuresOpen] = useState(false); const [appliedHideNonCompliant, setAppliedHideNonCompliant] = useState(false); @@ -109,7 +117,7 @@ export function ReportingClientArea({ scenarioId: selectedScenarioId!, hideNonCompliant: appliedHideNonCompliant, }), - enabled: !!selectedScenarioId, // only run when scenario selected + enabled: selectedScenarioId !== null, // only run when scenario selected or default selected keepPreviousData: true, // keep showing old data while loading new scenario or applying filter refetchOnWindowFocus: false, }); @@ -234,6 +242,9 @@ export function ReportingClientArea({ { setAppliedHideNonCompliant(value); }} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx index 610cb7a..40ad968 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx @@ -20,12 +20,16 @@ export interface ReportingFunctionalityButtonsProps { onApply: (value: boolean) => Promise | void; disabled?: boolean; + + /* Whether hideNonCompliant filter is available */ + canFilterNonCompliant?: boolean; } export function ReportingFunctionalityButtons({ hideNonCompliant, onApply, disabled = false, + canFilterNonCompliant = true, }: ReportingFunctionalityButtonsProps) { const [draftHideNonCompliant, setDraftHideNonCompliant] = useState(hideNonCompliant); @@ -97,10 +101,15 @@ export function ReportingFunctionalityButtons({ >
{/* Filter option */} -
+
setDraftHideNonCompliant(Boolean(checked)) } @@ -136,7 +145,7 @@ export function ReportingFunctionalityButtons({