implemented default filter

This commit is contained in:
Khalim Conn-Kowlessar 2026-02-24 23:47:58 +00:00
parent 0277c98632
commit bf02eb18d9
6 changed files with 420 additions and 41 deletions

View file

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

View file

@ -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<string, number> = {
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<string, number> = {
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,
});
}

View file

@ -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<number | null>(
null,
);
const [selectedScenarioId, setSelectedScenarioId] = useState<
number | "default" | null
>(null);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(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({
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioBusy}
canFilterNonCompliant={
selectedScenarioId !== null && selectedScenarioId !== "default"
}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}

View file

@ -20,12 +20,16 @@ export interface ReportingFunctionalityButtonsProps {
onApply: (value: boolean) => Promise<void> | void;
disabled?: boolean;
/* Whether hideNonCompliant filter is available */
canFilterNonCompliant?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
onApply,
disabled = false,
canFilterNonCompliant = true,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
@ -97,10 +101,15 @@ export function ReportingFunctionalityButtons({
>
<div className="space-y-5">
{/* Filter option */}
<div className="flex items-start gap-4">
<div
className={`flex items-start gap-4 ${
!canFilterNonCompliant ? "opacity-50 pointer-events-none" : ""
}`}
>
<Checkbox
id="hide-non-compliant"
checked={draftHideNonCompliant}
disabled={!canFilterNonCompliant}
onCheckedChange={(checked) =>
setDraftHideNonCompliant(Boolean(checked))
}
@ -136,7 +145,7 @@ export function ReportingFunctionalityButtons({
<Button
variant="ghost"
size="sm"
disabled={isApplying}
disabled={isApplying || !canFilterNonCompliant}
onClick={handleReset}
>
Reset
@ -145,7 +154,7 @@ export function ReportingFunctionalityButtons({
<Button
size="sm"
className="bg-brandmidblue hover:bg-hoverblue"
disabled={isApplying}
disabled={isApplying || !canFilterNonCompliant}
onClick={handleApply}
>
{isApplying ? "Applying…" : "Apply filters"}

View file

@ -16,8 +16,8 @@ export interface ScenarioOption {
interface ScenarioSelectorProps {
scenarios: ScenarioOption[];
selected: number | null;
onChange: (id: number | null) => void;
selected: number | null | "default";
onChange: (id: number | null | "default") => void;
}
export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
@ -30,9 +30,16 @@ export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
<span className="text-sm text-gray-600">Scenario:</span>
<Select
value={selected ? String(selected) : "none"}
value={
selected === null
? "none"
: selected === "default"
? "default"
: String(selected)
}
onValueChange={(val) => {
if (val === "none") onChange(null);
else if (val === "default") onChange("default");
else onChange(Number(val));
}}
>
@ -43,6 +50,10 @@ export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
<SelectContent>
<SelectItem value="none">No scenario (baseline only)</SelectItem>
<SelectItem value="default">
Best option (recommended plans)
</SelectItem>
{scenarios.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useMemo } from "react";
import { ScenarioSelector } from "./scenarioSelector";
export function ScenarioSelectorWrapper({
@ -11,23 +11,37 @@ export function ScenarioSelectorWrapper({
}: {
scenarios: { id: number; name: string }[];
portfolioId: number;
selectedScenarioId: number | null;
setSelectedScenarioId: (id: number | null) => void;
selectedScenarioId: number | null | "default";
setSelectedScenarioId: (id: number | null | "default") => void;
}) {
// The ID we will eventually pass into React Query
// const activeContextId = useMemo(
// () => selectedScenarioId ?? portfolioId,
// [selectedScenarioId, portfolioId]
// );
const [selectedScenarioName, setSelectedScenarioName] = useState<
string | null
>(null);
function handleSelect(id: number | null) {
function handleSelect(id: number | null | "default") {
setSelectedScenarioId(id);
const scenario = scenarios.find((s) => s.id === id);
setSelectedScenarioName(scenario ? scenario.name : null);
}
const selectionMeta = useMemo(() => {
if (selectedScenarioId === null) {
return {
label: "Baseline",
description: "Current portfolio performance",
className: "bg-gray-100 text-gray-600 border border-gray-200",
};
}
if (selectedScenarioId === "default") {
return {
label: "Recommended",
description: "Best upgrade plan per property",
className: "bg-brandmidblue text-white border border-brandblue",
};
}
const scenario = scenarios.find((s) => s.id === selectedScenarioId);
return {
label: scenario?.name ?? "Scenario",
description: "Custom upgrade scenario",
className: "bg-white text-gray-700 border border-gray-300",
};
}, [selectedScenarioId, scenarios]);
return (
<div className="flex items-center gap-4">
@ -37,13 +51,20 @@ export function ScenarioSelectorWrapper({
onChange={handleSelect}
/>
{selectedScenarioId !== null ? (
<div className="text-xs text-gray-500">
Scenario selected: {selectedScenarioName}
<div className="flex items-center gap-3">
<div
className={`
inline-flex items-center rounded-full px-3 py-1 text-xs font-medium
${selectionMeta.className}
`}
>
{selectionMeta.label}
</div>
) : (
<div className="text-xs text-gray-400">Using portfolio baseline</div>
)}
<span className="text-xs text-gray-400">
{selectionMeta.description}
</span>
</div>
</div>
);
}