mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #182 from Hestia-Homes/cascaded-deletes
Cascaded deletes
This commit is contained in:
commit
d6d5accf95
16 changed files with 23102 additions and 58 deletions
|
|
@ -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 }> },
|
||||
) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
6
src/app/db/migrations/0150_green_switch.sql
Normal file
6
src/app/db/migrations/0150_green_switch.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_plan_id_plan_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "recommendation_materials" DROP CONSTRAINT "recommendation_materials_recommendation_id_recommendation_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_plan_id_plan_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation_materials" ADD CONSTRAINT "recommendation_materials_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;
|
||||
3
src/app/db/migrations/0151_regular_lila_cheney.sql
Normal file
3
src/app/db/migrations/0151_regular_lila_cheney.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;
|
||||
3
src/app/db/migrations/0152_sparkling_kat_farrell.sql
Normal file
3
src/app/db/migrations/0152_sparkling_kat_farrell.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_plan_id_plan_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_plan_id_plan_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plan"("id") ON DELETE no action ON UPDATE no action;
|
||||
3
src/app/db/migrations/0153_large_machine_man.sql
Normal file
3
src/app/db/migrations/0153_large_machine_man.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
ALTER TABLE "plan_recommendations" DROP CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plan_recommendations" ADD CONSTRAINT "plan_recommendations_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE no action ON UPDATE no action;
|
||||
5657
src/app/db/migrations/meta/0150_snapshot.json
Normal file
5657
src/app/db/migrations/meta/0150_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5657
src/app/db/migrations/meta/0151_snapshot.json
Normal file
5657
src/app/db/migrations/meta/0151_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5657
src/app/db/migrations/meta/0152_snapshot.json
Normal file
5657
src/app/db/migrations/meta/0152_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
5657
src/app/db/migrations/meta/0153_snapshot.json
Normal file
5657
src/app/db/migrations/meta/0153_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1051,6 +1051,34 @@
|
|||
"when": 1769597155526,
|
||||
"tag": "0149_rich_luminals",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 150,
|
||||
"version": "7",
|
||||
"when": 1771753702175,
|
||||
"tag": "0150_green_switch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 151,
|
||||
"version": "7",
|
||||
"when": 1771754445853,
|
||||
"tag": "0151_regular_lila_cheney",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 152,
|
||||
"version": "7",
|
||||
"when": 1771754572720,
|
||||
"tag": "0152_sparkling_kat_farrell",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 153,
|
||||
"version": "7",
|
||||
"when": 1771757665072,
|
||||
"tag": "0153_large_machine_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -95,15 +95,15 @@ export const recommendation = pgTable(
|
|||
index("idx_recommendation_active_defaults")
|
||||
.on(table.id)
|
||||
.where(
|
||||
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
|
||||
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
|
||||
),
|
||||
|
||||
index("idx_recommendation_active_id_property")
|
||||
.on(table.id, table.propertyId)
|
||||
.where(
|
||||
sql`${table.default} = true AND ${table.alreadyInstalled} = false`
|
||||
sql`${table.default} = true AND ${table.alreadyInstalled} = false`,
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
export const unitQuantity: [string, ...string[]] = ["m2", "part", "kwp"];
|
||||
|
|
@ -117,7 +117,7 @@ export const recommendationMaterials = pgTable(
|
|||
mode: "bigint",
|
||||
})
|
||||
.notNull()
|
||||
.references(() => recommendation.id),
|
||||
.references(() => recommendation.id, { onDelete: "cascade" }),
|
||||
materialId: bigint("material_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => material.id),
|
||||
|
|
@ -129,9 +129,9 @@ export const recommendationMaterials = pgTable(
|
|||
},
|
||||
(table) => [
|
||||
index("recommendation_materials_recommendation_id_idx").on(
|
||||
table.recommendationId
|
||||
table.recommendationId,
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
// We create a plan type, for common plan types that we produce for clients
|
||||
|
|
@ -165,7 +165,7 @@ export const plan = pgTable(
|
|||
.references(() => property.id),
|
||||
|
||||
scenarioId: bigint("scenario_id", { mode: "bigint" }).references(
|
||||
() => scenario.id
|
||||
() => scenario.id,
|
||||
),
|
||||
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
|
|
@ -220,15 +220,15 @@ export const plan = pgTable(
|
|||
(table) => [
|
||||
index("idx_plan_portfolio_scenario").on(
|
||||
table.portfolioId,
|
||||
table.scenarioId
|
||||
table.scenarioId,
|
||||
),
|
||||
index("idx_plan_latest_per_property").on(
|
||||
table.portfolioId,
|
||||
table.scenarioId,
|
||||
table.propertyId,
|
||||
table.createdAt.desc()
|
||||
table.createdAt.desc(),
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
export const planRecommendations = pgTable(
|
||||
|
|
@ -248,9 +248,9 @@ export const planRecommendations = pgTable(
|
|||
index("idx_plan_recommendations_plan_id").on(table.planId),
|
||||
index("idx_plan_recommendations_plan_rec").on(
|
||||
table.planId,
|
||||
table.recommendationId
|
||||
table.recommendationId,
|
||||
),
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
export const HousingType: [string, ...string[]] = ["Private", "Social"];
|
||||
|
|
@ -273,7 +273,7 @@ export const scenario = pgTable("scenario", {
|
|||
alreadyInstalledFilePath: text("already_installed_file_path"),
|
||||
patchesFilePath: text("patches_file_path"),
|
||||
nonInvasideRecommendationsFilePath: text(
|
||||
"non_invasive_recommendations_file_path"
|
||||
"non_invasive_recommendations_file_path",
|
||||
),
|
||||
exclusions: text("exclusions"),
|
||||
multiPlan: boolean("multi_plan"),
|
||||
|
|
@ -298,10 +298,10 @@ export const scenario = pgTable("scenario", {
|
|||
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"),
|
||||
|
|
@ -345,7 +345,7 @@ export const installedMeasure = pgTable(
|
|||
index("idx_installed_measure_uprn_measure")
|
||||
.on(table.uprn, table.measureType)
|
||||
.where(sql`${table.isActive} = true`),
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
export type Plan = InferModel<typeof plan, "select">;
|
||||
|
|
@ -460,7 +460,7 @@ export const measuresDisplayLabels = {
|
|||
export type MeasureKey = keyof typeof measuresDisplayLabels;
|
||||
|
||||
export const measuresList: MeasureKey[] = Object.keys(
|
||||
measuresDisplayLabels
|
||||
measuresDisplayLabels,
|
||||
) as MeasureKey[];
|
||||
|
||||
export const MeasureKeyEnum = z.enum([
|
||||
|
|
|
|||
|
|
@ -39,19 +39,21 @@ 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 = `/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 +62,11 @@ async function fetchScenarioMeasures({
|
|||
scenarioId,
|
||||
}: {
|
||||
portfolioId: number;
|
||||
scenarioId: number;
|
||||
scenarioId: number | "default";
|
||||
}) {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`,
|
||||
);
|
||||
const path = `/api/portfolio/${portfolioId}/scenario/${scenarioId}/measures`;
|
||||
|
||||
const res = await fetch(path);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to load measures");
|
||||
|
|
@ -79,9 +81,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 +111,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 +236,9 @@ export function ReportingClientArea({
|
|||
<ReportingFunctionalityButtons
|
||||
hideNonCompliant={appliedHideNonCompliant}
|
||||
disabled={scenarioBusy}
|
||||
canFilterNonCompliant={
|
||||
selectedScenarioId !== null && selectedScenarioId !== "default"
|
||||
}
|
||||
onApply={async (value) => {
|
||||
setAppliedHideNonCompliant(value);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue