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 ce373b6..8fc292b 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -69,6 +69,8 @@ export async function GET( const sid = BigInt(scenarioId); const hideNonCompliant = request.nextUrl.searchParams.get("hideNonCompliant") === "true"; + const useOriginalBaseline = + request.nextUrl.searchParams.get("useOriginalBaseline") === "true"; /* ---------------------------------------------------------- Query 0 — scenario definition @@ -129,7 +131,18 @@ export async function GET( END )::float AS total_sap_uplift FROM latest_plans lp - JOIN property p ON p.id = lp.property_id; + JOIN property p ON p.id = lp.property_id + -- Conditional filter: only restrict by original_sap_points when the toggle is on + -- AND the scenario has an EPC target. Written as an OR chain so Postgres evaluates + -- it as a single WHERE clause — avoiding the need to dynamically build the query + -- string in application code (which would require string concatenation and risks + -- SQL injection). The OR short-circuits left-to-right: if the first or second + -- condition is true, the third is never evaluated, so all rows pass through. + WHERE ( + ${useOriginalBaseline} = false -- toggle off → include everything + OR ${minSap}::float IS NULL -- no EPC target → nothing to filter on + OR p.original_sap_points < ${minSap}::float -- actual filter + ); `); const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; @@ -162,8 +175,14 @@ export async function GET( COALESCE(fp.total_uplift, 0) )::float AS total_funding FROM latest_plans lp + JOIN property p ON p.id = lp.property_id LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0; + WHERE lp.cost_of_works > 0 + AND ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ); `); const upgraded = upgradedResult.rows[0] as UpgradedAggregates; @@ -223,6 +242,11 @@ export async function GET( AND plan.post_sap_points >= ${minSap}::float ) ) + AND ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ) ORDER BY created_at DESC LIMIT 1 ) lp ON true @@ -256,6 +280,11 @@ export async function GET( AND plan.post_sap_points >= ${minSap}::float ) ) + AND ( + ${useOriginalBaseline} = false + OR ${minSap}::float IS NULL + OR p.original_sap_points < ${minSap}::float + ) ORDER BY created_at DESC LIMIT 1 ) lp ON true diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 47dfeaf..8d89323 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -39,13 +39,16 @@ async function fetchScenarioReport({ portfolioId, scenarioId, hideNonCompliant, + useOriginalBaseline, }: { portfolioId: number; scenarioId: number | "default"; hideNonCompliant: boolean; + useOriginalBaseline: boolean; }) { const params = new URLSearchParams({ hideNonCompliant: String(hideNonCompliant), + useOriginalBaseline: String(useOriginalBaseline), }); const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`; @@ -89,6 +92,8 @@ export function ReportingClientArea({ const [measuresOpen, setMeasuresOpen] = useState(false); const [appliedHideNonCompliant, setAppliedHideNonCompliant] = useState(false); + const [appliedUseOriginalBaseline, setAppliedUseOriginalBaseline] = + useState(false); const [showToast, setShowToast] = useState(false); const drawerOpen = Boolean(selectedScenarioId); @@ -107,12 +112,14 @@ export function ReportingClientArea({ portfolioId, selectedScenarioId, appliedHideNonCompliant, + appliedUseOriginalBaseline, ], queryFn: () => fetchScenarioReport({ portfolioId, scenarioId: selectedScenarioId!, hideNonCompliant: appliedHideNonCompliant, + useOriginalBaseline: appliedUseOriginalBaseline, }), enabled: selectedScenarioId !== null, // only run when scenario selected or default selected keepPreviousData: true, // keep showing old data while loading new scenario or applying filter @@ -238,12 +245,14 @@ export function ReportingClientArea({ { - setAppliedHideNonCompliant(value); + onApply={async ({ hideNonCompliant, useOriginalBaseline }) => { + setAppliedHideNonCompliant(hideNonCompliant); + setAppliedUseOriginalBaseline(useOriginalBaseline); }} /> diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx index 40ad968..9a70414 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingFunctionalityButtons.tsx @@ -13,33 +13,45 @@ export interface ReportingFunctionalityButtonsProps { /** Currently applied value */ hideNonCompliant: boolean; + /** Currently applied value */ + useOriginalBaseline: boolean; + /** * Explicit user action. * Parent decides what "apply" means (refetch, mutate, etc). */ - onApply: (value: boolean) => Promise | void; + onApply: (options: { + hideNonCompliant: boolean; + useOriginalBaseline: boolean; + }) => Promise | void; disabled?: boolean; - /* Whether hideNonCompliant filter is available */ + /* Whether filters are available (only for specific non-default scenarios) */ canFilterNonCompliant?: boolean; } export function ReportingFunctionalityButtons({ hideNonCompliant, + useOriginalBaseline, onApply, disabled = false, canFilterNonCompliant = true, }: ReportingFunctionalityButtonsProps) { const [draftHideNonCompliant, setDraftHideNonCompliant] = useState(hideNonCompliant); + const [draftUseOriginalBaseline, setDraftUseOriginalBaseline] = + useState(useOriginalBaseline); const [isApplying, setIsApplying] = useState(false); async function handleApply() { try { setIsApplying(true); - await onApply(draftHideNonCompliant); + await onApply({ + hideNonCompliant: draftHideNonCompliant, + useOriginalBaseline: draftUseOriginalBaseline, + }); } finally { setIsApplying(false); } @@ -50,7 +62,8 @@ export function ReportingFunctionalityButtons({ // reset the filter and trigger the fetch setIsApplying(true); setDraftHideNonCompliant(false); - await onApply(false); + setDraftUseOriginalBaseline(false); + await onApply({ hideNonCompliant: false, useOriginalBaseline: false }); } finally { setIsApplying(false); } @@ -61,6 +74,7 @@ export function ReportingFunctionalityButtons({ onOpenChange={(open) => { if (open) { setDraftHideNonCompliant(hideNonCompliant); + setDraftUseOriginalBaseline(useOriginalBaseline); } }} > @@ -72,7 +86,7 @@ export function ReportingFunctionalityButtons({ className={` relative flex items-center gap-2 ${ - hideNonCompliant + hideNonCompliant || useOriginalBaseline ? "border-brandmidblue/40 bg-brandlightblue/40" : "" } @@ -81,7 +95,7 @@ export function ReportingFunctionalityButtons({ {/* Filter icon */} Filter options - {hideNonCompliant && ( + {(hideNonCompliant || useOriginalBaseline) && ( )} @@ -140,6 +154,47 @@ export function ReportingFunctionalityButtons({ + {/* Use original SAP points */} +
+ + setDraftUseOriginalBaseline(Boolean(checked)) + } + className="mt-1" + /> + + +
+ {/* Actions */}