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..69f90c2 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -1,7 +1,8 @@ import { db } from "@/app/db/db"; -import { sql } from "drizzle-orm"; +import { sql, 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 = { n_units: number; @@ -21,19 +22,99 @@ type UpgradedAggregates = { total_funding: number | null; }; +const EPC_MIN_SAP: Record = { + A: 92, + B: 81, + C: 69, + D: 55, + E: 39, + F: 21, + G: 0, +}; + +type PlanFilterContext = { + hideNonCompliant: boolean; + scenarioGoal: PortfolioGoalType; + scenarioGoalValue: string; +}; + +function resolvePlanFilters(ctx: PlanFilterContext): SQL[] { + const conditions: SQL[] = []; + + if (ctx.hideNonCompliant && ctx.scenarioGoal === "Increasing EPC") { + const minSap = EPC_MIN_SAP[ctx.scenarioGoalValue]; + + if (minSap !== undefined) { + conditions.push(sql` + post_sap_points IS NOT NULL + AND post_sap_points >= ${minSap} + `); + } + } + + // Additional filters can be added in the future + + return conditions; +} + +function andConditions(conditions: SQL[]): SQL { + if (conditions.length === 0) { + return sql``; + } + + return sql`AND ${sql.join(conditions, sql` AND `)}`; +} + 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; + + // We shouldn't have missing scenario id but this is defensive to prevent clear errors and speed up diagnosis if it happens + if (!scenarioId || scenarioId === "null") { + return NextResponse.json({ error: "Invalid scenarioId" }, { status: 400 }); + } const pid = BigInt(portfolioId); const sid = BigInt(scenarioId); + const hideNonCompliant = + request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + + // ---------------------------------------------------------- + // Query 0 - get the 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 }); + } // // ---------------------------------------------------------- // QUERY 1 — Baseline metrics for *all* properties // ---------------------------------------------------------- // + + // resolve filters: + const filterConditions = resolvePlanFilters({ + hideNonCompliant, + scenarioGoal: scenario.goal, + scenarioGoalValue: scenario.goal_value, + }); + const baselineResult = await db.execute(sql` WITH latest_plans AS ( SELECT DISTINCT ON (property_id) @@ -41,6 +122,7 @@ export async function GET( FROM plan WHERE portfolio_id = ${pid} AND scenario_id = ${sid} + ${andConditions(filterConditions)} ORDER BY property_id, created_at DESC ) @@ -76,7 +158,7 @@ JOIN property p if (!baseline || baseline.n_units === 0) { return NextResponse.json( { error: "No plans found for this scenario" }, - { status: 404 } + { status: 404 }, ); } @@ -102,6 +184,7 @@ JOIN property p FROM plan WHERE portfolio_id = ${pid} AND scenario_id = ${sid} + ${andConditions(filterConditions)} ORDER BY property_id, created_at DESC ) 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/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 04875d8..6f586e6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -36,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()); @@ -78,7 +83,8 @@ export function ReportingClientArea({ null, ); const [measuresOpen, setMeasuresOpen] = useState(false); - const [hideNonCompliant, setHideNonCompliant] = useState(false); + const [appliedHideNonCompliant, setAppliedHideNonCompliant] = + useState(false); const drawerOpen = Boolean(selectedScenarioId); @@ -88,13 +94,20 @@ 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 }); @@ -212,9 +225,11 @@ export function ReportingClientArea({ { + setAppliedHideNonCompliant(value); + }} /> {/* Download PDF */}