Merge pull request #174 from Hestia-Homes/feature/filter-bad-plans

Feature/filter bad plans
This commit is contained in:
KhalimCK 2026-02-09 12:05:05 +00:00 committed by GitHub
commit 389b40ead1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 803 additions and 316 deletions

View file

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

View file

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

View file

@ -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<string, PortfolioGoalType>;
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"),

View file

@ -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<any>; 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<MetricKey, { icon: React.ComponentType<any>; color: string }>;
};
const epcColors: Record<string, string> = {
A: "text-epc_a",
@ -38,24 +44,38 @@ const epcColors: Record<string, string> = {
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 <div className={`animate-pulse rounded bg-gray-200 ${className}`} />;
}
/* ───────────────────────────────────────────── */
/* 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 (
<Card
key={c.key}
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
className="h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
>
{/* Header */}
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<motion.div whileHover={{ scale: 1.05 }}>
<Icon className={`h-5 w-5 ${color}`} />
</motion.div>
<CardTitle className="text-md font-medium text-gray-700">
{c.title}
</CardTitle>
{loading ? (
<>
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-4 w-32" />
</>
) : (
<>
<motion.div whileHover={{ scale: 1.05 }}>
<Icon className={`h-5 w-5 ${color}`} />
</motion.div>
<CardTitle className="text-md font-medium text-gray-700">
{c.title}
</CardTitle>
</>
)}
</CardHeader>
{/* Content */}
<CardContent className="flex flex-1 flex-col gap-2">
{/* BASELINE + SCENARIO ROW */}
<div
className={`flex ${
hasScenario ? "justify-between" : "justify-start"
} items-start`}
>
{/* BASELINE COLUMN */}
{/* Baseline */}
<div className="flex flex-col">
<span className="text-xs text-gray-500">Baseline</span>
<div className="flex items-baseline gap-2">
<span
className={
c.isEpc
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue print-text-solid"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
</span>
{/* units next to baseline average */}
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
{/* Baseline total */}
{c.baselineTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
{loading ? (
<Skeleton className="h-8 w-28 mt-1" />
) : (
<div className="flex items-baseline gap-2">
<span
className={
c.isEpc
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
</span>
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
)}
{c.baselineTotal !== undefined &&
(loading ? (
<Skeleton className="h-4 w-36 mt-1" />
) : (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
))}
</div>
{/* SCENARIO COLUMN */}
{/* Scenario */}
{hasScenario && c.scenario && (
<div className="flex flex-col text-right">
<span className="text-xs text-gray-500">Scenario</span>
{/* average + delta + units row */}
<div className="flex items-baseline justify-end gap-2">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
overlay?.avgSap?.scenario ??
(averages.avg_sap || 0)
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
{c.delta && <span>{c.delta}</span>}
</div>
{/* Scenario total */}
{c.scenarioTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
{loading ? (
<Skeleton className="h-7 w-24 mt-1 ml-auto" />
) : (
<div className="flex items-baseline justify-end gap-2">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
overlay?.avgSap?.scenario ??
(averages.avg_sap || 0),
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
{c.delta && <span>{c.delta}</span>}
</div>
)}
{c.scenarioTotal !== undefined &&
(loading ? (
<Skeleton className="h-4 w-36 mt-1 ml-auto" />
) : (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
))}
</div>
)}
</div>
@ -246,7 +275,11 @@ export function DashboardSummaryCards({
</CardContent>
<CardFooter>
<p className="text-xs text-gray-500">{c.subtitle}</p>
{loading ? (
<Skeleton className="h-3 w-3/4" />
) : (
<p className="text-xs text-gray-500">{c.subtitle}</p>
)}
</CardFooter>
</Card>
);

View file

@ -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<number | null>(
null
null,
);
const [measuresOpen, setMeasuresOpen] = useState<boolean>(false);
const [appliedHideNonCompliant, setAppliedHideNonCompliant] =
useState<boolean>(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 (
<>
<div className="flex items-center justify-between gap-4">
@ -193,33 +215,43 @@ export function ReportingClientArea({
{/* RIGHT: Actions (only when scenario selected) */}
{selectedScenarioId && (
<div className="flex items-center gap-2">
{/* Show measures */}
<button
onClick={() => setMeasuresOpen(true)}
disabled={scenarioLoading}
disabled={true}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
scenarioLoading
scenarioBusy
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
{scenarioLoading ? "Loading…" : "Show measures"}
{scenarioBusy ? "Loading…" : "Show measures"}
</button>
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioBusy}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}
/>
{/* Download PDF */}
<button
onClick={() => {
window.open(
`/portfolio/${portfolioId}/reporting/pdf?scenarioId=${selectedScenarioId}`,
"_blank"
"_blank",
);
}}
disabled={scenarioLoading}
disabled={scenarioBusy}
className={`
rounded-md border px-3 py-2 text-sm font-medium transition
${
scenarioLoading
scenarioBusy
? "border-gray-200 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}
@ -247,7 +279,11 @@ export function ReportingClientArea({
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
<ScenarioFinancialDrawer
open={drawerOpen}
metrics={scenarioSpecific}
loading={scenarioBusy}
/>
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
<DashboardSummaryCards
@ -256,6 +292,7 @@ export function ReportingClientArea({
averages={activeMetrics.averages}
estimatedCounts={activeMetrics.estimatedCounts}
scenarioOverlay={scenarioOverlay}
loading={scenarioBusy}
/>
<BreakdownChart

View file

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/app/shadcn_components/ui/dropdown-menu";
import { Button } from "@/app/shadcn_components/ui/button";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
export interface ReportingFunctionalityButtonsProps {
/** Currently applied value */
hideNonCompliant: boolean;
/**
* Explicit user action.
* Parent decides what "apply" means (refetch, mutate, etc).
*/
onApply: (value: boolean) => Promise<void> | void;
disabled?: boolean;
}
export function ReportingFunctionalityButtons({
hideNonCompliant,
onApply,
disabled = false,
}: ReportingFunctionalityButtonsProps) {
const [draftHideNonCompliant, setDraftHideNonCompliant] =
useState<boolean>(hideNonCompliant);
const [isApplying, setIsApplying] = useState(false);
async function handleApply() {
try {
setIsApplying(true);
await onApply(draftHideNonCompliant);
} finally {
setIsApplying(false);
}
}
async function handleReset() {
try {
// reset the filter and trigger the fetch
setIsApplying(true);
setDraftHideNonCompliant(false);
await onApply(false);
} finally {
setIsApplying(false);
}
}
return (
<DropdownMenu
onOpenChange={(open) => {
if (open) {
setDraftHideNonCompliant(hideNonCompliant);
}
}}
>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={disabled || isApplying}
className={`
relative flex items-center gap-2
${
hideNonCompliant
? "border-brandmidblue/40 bg-brandlightblue/40"
: ""
}
`}
>
{/* Filter icon */}
<svg
className={`h-4 w-4 ${
hideNonCompliant ? "text-brandmidblue" : "text-gray-500"
}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M3 4a1 1 0 011-1h12a1 1 0 01.8 1.6l-4.8 6.4V16a1 1 0 01-1.447.894l-2-1A1 1 0 018 14v-2.999L3.2 5.6A1 1 0 013 4z" />
</svg>
Filter options
{hideNonCompliant && (
<span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-brandmidblue" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-80 p-4 shadow-tremor-dropdown"
>
<div className="space-y-5">
{/* Filter option */}
<div className="flex items-start gap-4">
<Checkbox
id="hide-non-compliant"
checked={draftHideNonCompliant}
onCheckedChange={(checked) =>
setDraftHideNonCompliant(Boolean(checked))
}
className="mt-1"
/>
<label
htmlFor="hide-non-compliant"
className="cursor-pointer space-y-1"
>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 leading-snug">
<svg
className="h-4 w-4 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.172 7.707 8.879a1 1 0 00-1.414 1.414L9 13l4.707-4.707z"
clipRule="evenodd"
/>
</svg>
Hide non-compliant properties
</div>
<div className="text-xs text-gray-500 leading-relaxed">
Exclude properties that dont meet the defined upgrade targets
</div>
</label>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
variant="ghost"
size="sm"
disabled={isApplying}
onClick={handleReset}
>
Reset
</Button>
<Button
size="sm"
className="bg-brandmidblue hover:bg-hoverblue"
disabled={isApplying}
onClick={handleApply}
>
{isApplying ? "Applying…" : "Apply filters"}
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -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}`,
)}
>
<div className="rounded-[7px] bg-white h-full">{children}</div>
@ -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 (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full animate-pulse">
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
<div className="h-6 w-3/4 bg-gray-200 rounded" />
</div>
</GradientCard>
);
}
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="flex flex-col items-center justify-center p-4 h-full text-center">
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">{value}</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
{loading ? (
<div className="w-full animate-pulse space-y-3">
<div className="h-6 w-6 mx-auto rounded bg-gray-200" />
<div className="h-8 w-2/3 mx-auto rounded bg-gray-200" />
<div className="h-3 w-1/2 mx-auto rounded bg-gray-200" />
</div>
) : (
<>
<Icon className={clsx("h-6 w-6 mb-2", color)} />
<span className="text-3xl font-semibold text-gray-900">
{value}
</span>
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
</>
)}
</div>
</GradientCard>
);
@ -108,6 +133,7 @@ function PairedMetric({
gradient,
iconClassName = "text-gray-700",
variant = "green",
loading = false,
}: {
title: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
@ -116,30 +142,62 @@ function PairedMetric({
gradient: string;
iconClassName?: string;
variant?: "green" | "blue" | "purple";
loading?: boolean;
}) {
if (loading || !primary.value || !secondary.value) {
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full animate-pulse">
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
<div className="h-6 w-3/4 bg-gray-200 rounded" />
</div>
</GradientCard>
);
}
return (
<GradientCard gradient={gradient} variant={variant}>
<div className="p-4 h-full">
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">{title}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
{loading ? (
<div className="animate-pulse space-y-4">
<div className="h-4 w-1/3 rounded bg-gray-200" />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
<div className="space-y-2">
<div className="h-3 w-2/3 rounded bg-gray-200" />
<div className="h-6 w-full rounded bg-gray-200" />
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Icon className={clsx("h-5 w-5", iconClassName)} />
<span className="text-sm font-semibold text-gray-900">
{title}
</span>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500">{primary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{primary.value}
</p>
</div>
<div>
<p className="text-xs text-gray-500">{secondary.label}</p>
<p className="text-xl font-semibold text-gray-900">
{secondary.value}
</p>
</div>
</div>
</>
)}
</div>
</GradientCard>
);
@ -172,7 +230,7 @@ function Section({
<div
className={clsx(
"rounded-lg p-2 bg-white shadow-sm border",
accentColor
accentColor,
)}
>
<Icon className="h-5 w-5" />
@ -191,6 +249,22 @@ function Section({
);
}
/* ───────────────────────────────────────────── */
/* Loading Skeleton for dashboard cards */
/* ───────────────────────────────────────────── */
function LoadingOverlay() {
return (
<div className="absolute inset-0 z-20 rounded-lg bg-white/70 backdrop-blur-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-6 animate-pulse">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-28 rounded-lg bg-gray-200" />
))}
</div>
</div>
);
}
/* ───────────────────────────────────────────── */
/* Main Drawer */
/* ───────────────────────────────────────────── */
@ -198,10 +272,11 @@ function Section({
export function ScenarioFinancialDrawer({
open,
metrics,
loading = false,
}: ScenarioFinancialDrawerProps) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
@ -228,14 +303,17 @@ export function ScenarioFinancialDrawer({
iconClassName="text-green-700"
primary={{
label: "Total carbon saved (t/yr)",
value: formatNumber(metrics.totalCarbonSaved),
value: metrics ? formatNumber(metrics.totalCarbonSaved) : "",
}}
secondary={{
label: "Average per unit (t/yr)",
value: formatNumber(metrics.averageCaribonSaved),
value: metrics
? formatNumber(metrics.averageCaribonSaved)
: "",
}}
gradient={gradients.green}
variant="green"
loading={loading}
/>
<PairedMetric
@ -244,23 +322,29 @@ export function ScenarioFinancialDrawer({
iconClassName="text-green-700"
primary={{
label: "Total bill savings (£/yr)",
value: `£${formatNumber(metrics.totalBillsSaved)}`,
value: metrics
? `£${formatNumber(metrics.totalBillsSaved)}`
: "",
}}
secondary={{
label: "Average per unit (£/yr)",
value: `£${formatNumber(metrics.averageBillsSaved)}`,
value: metrics
? `£${formatNumber(metrics.averageBillsSaved)}`
: "",
}}
gradient={gradients.green}
variant="green"
loading={loading}
/>
<Metric
label="Homes upgraded"
value={metrics.nUnits}
value={metrics ? metrics.nUnits : ""}
icon={HomeIcon}
color="text-green-700"
gradient={gradients.green}
variant="green"
loading={loading}
/>
</Section>
@ -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}
/>
<Metric
label="Gross cost per unit"
value={`£${formatNumber(metrics.grossPerUnit)}`}
value={metrics ? `£${formatNumber(metrics.grossPerUnit)}` : ""}
icon={HomeIcon}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
value={metrics ? `£${formatNumber(metrics.contingency)}` : ""}
icon={Gauge}
color="text-blue-600"
gradient={gradients.blue}
variant="blue"
loading={loading}
/>
</Section>
@ -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}
/>
</Section>
</div>

View file

@ -23,7 +23,7 @@ export async function getPortfolioCounts(portfolioId: number): Promise<number> {
}
export async function getAverages(
portfolioId: number
portfolioId: number,
): Promise<AverageMetrics> {
const result = await db.execute<AverageMetrics>(sql`
SELECT
@ -69,7 +69,7 @@ export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
}
export async function getCountByAgeBand(
portfolioId: number
portfolioId: number,
): Promise<AgeBandCount[]> {
const result = await db.execute<AgeBandCount>(sql`
SELECT
@ -96,23 +96,27 @@ export async function getCountByAgeBand(
}
export async function getCountByEpcBand(
portfolioId: number
portfolioId: number,
): Promise<EpcBandCount[]> {
const result = await db.execute<EpcBandCount>(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<EstimatedCounts> {
const result = await db.execute<EstimatedCounts>(sql`
SELECT
@ -142,7 +146,7 @@ export async function getEstimatedCounts(
}
export async function getCountByPropertyType(
portfolioId: number
portfolioId: number,
): Promise<PropertyTypeCount[]> {
const result = await db.execute<PropertyTypeCount>(sql`
SELECT property_type AS type, COUNT(*)::int AS count
@ -173,7 +177,7 @@ export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
}
export async function getLikelyDowngrades(
portfolioId: number
portfolioId: number,
): Promise<number> {
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<BaselineMetrics> {
const [
total,

View file

@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) {
export const config = {
matcher: [
// Protect only your apps authenticated areas
// Protect only apps authenticated areas
"/home/:path*",
"/portfolio/:path*",
"/search/:path*",