mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge github.com:Hestia-Homes/assessment-model into feature/guiness_live_tracking
This commit is contained in:
commit
5d77576269
21 changed files with 6888 additions and 370 deletions
|
|
@ -35,7 +35,6 @@ const PresignedUrlBodySchema = z
|
|||
|
||||
export async function POST(request: NextRequest) {
|
||||
// For the moment, this api specifically handles uploads of csvs
|
||||
console.log("Triggering plan build");
|
||||
|
||||
const body = await request.json();
|
||||
let validatedBody;
|
||||
|
|
@ -78,7 +77,7 @@ export async function POST(request: NextRequest) {
|
|||
JSON.stringify({ msg: "Error triggering plan" }),
|
||||
{
|
||||
status: 500,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const TitleMap = {
|
|||
loft_insulation: "Loft Insulation",
|
||||
room_roof_insulation: "Room Roof Insulation",
|
||||
flat_roof_insulation: "Flat Roof Insulation",
|
||||
sloping_ceiling_insulation: "Sloping Ceiling Insulation",
|
||||
// Floor
|
||||
solid_floor_insulation: "Solid Floor Insulation",
|
||||
suspended_floor_insulation: "Suspended Floor Insulation",
|
||||
|
|
@ -104,12 +105,12 @@ export default function RecommendationCard({
|
|||
setTotalKwhSavings,
|
||||
}: RecommendationCardProps) {
|
||||
const defaultComponent = recommendationData.find(
|
||||
(rec: Recommendation) => rec.default
|
||||
(rec: Recommendation) => rec.default,
|
||||
) as Recommendation;
|
||||
|
||||
// A recommendation type could have no default recommendation, so we need to check if it exists
|
||||
const alreadyInstalled = recommendationData.some(
|
||||
(rec) => rec.alreadyInstalled
|
||||
(rec) => rec.alreadyInstalled,
|
||||
);
|
||||
|
||||
const [cardComponent, setCardComponent] =
|
||||
|
|
|
|||
30
src/app/db/migrations/0149_rich_luminals.sql
Normal file
30
src/app/db/migrations/0149_rich_luminals.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
CREATE TYPE "public"."aspect_type" AS ENUM('material', 'condition', 'type', 'area', 'configuration', 'presence', 'risk', 'severity', 'location', 'finish', 'insulation', 'pointing', 'spalling', 'lintels', 'cladding', 'category', 'quantity', 'adequacy', 'rating', 'strategy', 'extent', 'distribution', 'structure', 'covering', 'fire_rating', 'external_decoration', 'work_required', 'age_band', 'construction_type', 'classification', 'system');--> statement-breakpoint
|
||||
CREATE TYPE "public"."element_type" AS ENUM('property', 'property_construction_type', 'property_classification', 'property_age_band', 'storey_count', 'floor_level', 'floor_level_front_door', 'accessible_housing_register', 'asbestos', 'quality_standard', 'ccu', 'passenger_lift', 'stairlift', 'disabled_hoist_tracking', 'disabled_facilities', 'steps_to_front_door', 'roof', 'pitched_roof_covering', 'flat_roof_covering', 'rainwater_goods', 'loft_insulation', 'porch_canopy', 'chimney', 'fascia', 'soffit', 'fascia_soffit_bargeboards', 'gutters', 'store_roof', 'garage_roof', 'garage_and_store_roof', 'external_wall', 'external_noise_insulation', 'primary_wall', 'secondary_wall', 'downpipes', 'external_decoration', 'cladding', 'spandrel_panels', 'garage_walls', 'party_wall_fire_break', 'external_brickwork_pointing', 'internal_downpipes_external_area', 'external_windows', 'communal_windows', 'secondary_glazing', 'store_windows', 'garage_windows', 'garage_and_store_windows', 'external_door', 'front_door', 'rear_door', 'store_door', 'garage_door', 'garage_and_store_door', 'communal_entrance_door', 'main_door', 'block_entrance_door', 'lintel', 'patio_french_door', 'door_entry_handset', 'paths_and_hardstandings', 'parking_areas', 'boundary_walls', 'front_fencing', 'rear_fencing', 'side_fencing', 'rear_gate', 'front_gate', 'gates', 'retaining_walls', 'private_balcony', 'balcony_balustrade', 'outbuildings', 'garage_structure', 'paving', 'roads', 'soil_and_vent', 'solar_thermals', 'drop_kerb', 'outbuilding_overhaul', 'external_structural_defects', 'access_ramp', 'kitchen', 'kitchen_space_layout', 'tenant_installed_kitchen', 'kitchen_extractor_fan', 'bathroom', 'secondary_bathroom', 'secondary_toilet', 'bathroom_extractor_fan', 'additional_wc_or_whb', 'bathroom_remaining_life_source', 'kitchen_remaining_life_source', 'central_heating', 'heating_boiler', 'heating_distribution', 'secondary_heating', 'hot_water_system', 'cold_water_storage', 'heating_system', 'boiler_fuel', 'water_heating', 'programmable_heating', 'community_heating', 'gas_available', 'heat_recovery_units', 'heating_improvements', 'electrical_wiring', 'consumer_unit', 'smoke_detection', 'heat_detection', 'carbon_monoxide_detection', 'fire_door_rating', 'fire_risk_assessment', 'internal_wiring', 'electrics', 'communal_heating', 'communal_boiler', 'communal_electrics', 'communal_fire_alarm', 'communal_emergency_lighting', 'communal_door_entry', 'communal_cctv', 'communal_bin_store', 'communal_bin_store_doors', 'communal_bin_store_walls', 'communal_bin_store_roof', 'communal_refuse_chute', 'communal_floor_covering', 'communal_kitchen', 'communal_bathroom', 'communal_toilets', 'communal_gates', 'communal_lift', 'communal_passenger_lift', 'communal_balcony_walkway', 'communal_entrance', 'communal_internal_decorations', 'communal_internal_floor', 'communal_walkways', 'communal_external_doors', 'communal_stairs', 'communal_aerial', 'communal_aov', 'communal_internal_doors', 'communal_lateral_mains', 'communal_lighting', 'communal_lighting_conductor', 'communal_store_roof', 'communal_store_walls', 'communal_store_doors', 'communal_warden_call_system', 'communal_bms', 'communal_booster_pump', 'communal_dry_riser', 'communal_wet_riser', 'communal_cold_water_storage', 'communal_sprinkler', 'communal_plug_sockets', 'communal_circulation_space', 'ffhh_damp', 'ffhh_hold_and_cold_water', 'ffhh_drainage_lavatories', 'ffhh_neglected', 'ffhh_natural_light', 'ffhh_ventilation', 'ffhh_food_prep_and_washup', 'ffhh_unsafe_layout', 'ffhh_unstable_building', 'hhsrs_damp_and_mould', 'hhsrs_excess_cold', 'hhsrs_excess_heat', 'hhsrs_asbestos_and_mmf', 'hhsrs_biocides', 'hhsrs_carbon_monoxide', 'hhsrs_lead', 'hhsrs_radiation', 'hhsrs_uncombusted_fuel_gas', 'hhsrs_volatile_organic_compounds', 'hhsrs_crowding_and_space', 'hhsrs_entry_by_intruders', 'hhsrs_lighting', 'hhsrs_noise', 'hhsrs_domestic_hygiene_pests_refuse', 'hhsrs_food_safety', 'hhsrs_personal_hygiene_sanitation', 'hhsrs_water_supply', 'hhsrs_falls_associated_with_baths', 'hhsrs_falls_on_level_surfaces', 'hhsrs_falls_on_stairs', 'hhsrs_falls_between_levels', 'hhsrs_electrical_hazards', 'hhsrs_fire', 'hhsrs_flames_hot_surfaces', 'hhsrs_collision_and_entrapment', 'hhsrs_collision_hazards_low_headroom', 'hhsrs_explosions', 'hhsrs_ergonomics', 'hhsrs_structural_collapse', 'hhsrs_amenities');--> statement-breakpoint
|
||||
CREATE TABLE "aspect_condition" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"element_id" bigint NOT NULL,
|
||||
"aspect_type" "aspect_type" NOT NULL,
|
||||
"aspect_instance" integer NOT NULL,
|
||||
"value" text,
|
||||
"quantity" integer,
|
||||
"install_date" date,
|
||||
"renewal_year" integer,
|
||||
"comments" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "element" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"survey_id" bigint NOT NULL,
|
||||
"element_type" "element_type" NOT NULL,
|
||||
"element_instance" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "property_condition_survey" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"uprn" bigint NOT NULL,
|
||||
"date" date NOT NULL,
|
||||
"source" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "aspect_condition" ADD CONSTRAINT "aspect_condition_element_id_element_id_fk" FOREIGN KEY ("element_id") REFERENCES "public"."element"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "element" ADD CONSTRAINT "element_survey_id_property_condition_survey_id_fk" FOREIGN KEY ("survey_id") REFERENCES "public"."property_condition_survey"("id") ON DELETE no action ON UPDATE no action;
|
||||
5657
src/app/db/migrations/meta/0149_snapshot.json
Normal file
5657
src/app/db/migrations/meta/0149_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1044,6 +1044,13 @@
|
|||
"when": 1767823836420,
|
||||
"tag": "0148_first_gamma_corps",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 149,
|
||||
"version": "7",
|
||||
"when": 1769597155526,
|
||||
"tag": "0149_rich_luminals",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
28
src/app/db/schema/condition/aspect_condition.ts
Normal file
28
src/app/db/schema/condition/aspect_condition.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { bigint, bigserial, date, integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { element } from "./element";
|
||||
import { aspectType } from "./aspect_type";
|
||||
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
|
||||
|
||||
export const aspectCondition = pgTable("aspect_condition", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
|
||||
elementId: bigint("element_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => element.id),
|
||||
|
||||
aspectType: aspectType("aspect_type").notNull(),
|
||||
aspectInstance: integer("aspect_instance").notNull(),
|
||||
|
||||
value: text("value"),
|
||||
quantity: integer("quantity"),
|
||||
installDate: date("install_date"),
|
||||
renewalYear: integer("renewal_year"),
|
||||
comments: text("comments"),
|
||||
});
|
||||
|
||||
export type AspectConditionRow =
|
||||
InferSelectModel<typeof aspectCondition>;
|
||||
|
||||
export type NewAspectConditionRow =
|
||||
InferInsertModel<typeof aspectCondition>;
|
||||
|
||||
37
src/app/db/schema/condition/aspect_type.ts
Normal file
37
src/app/db/schema/condition/aspect_type.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { pgEnum } from "drizzle-orm/pg-core";
|
||||
|
||||
export const aspectType = pgEnum("aspect_type", [
|
||||
"material",
|
||||
"condition",
|
||||
"type",
|
||||
"area",
|
||||
"configuration",
|
||||
"presence",
|
||||
"risk",
|
||||
"severity",
|
||||
"location",
|
||||
"finish",
|
||||
"insulation",
|
||||
"pointing",
|
||||
"spalling",
|
||||
"lintels",
|
||||
"cladding",
|
||||
"category",
|
||||
"quantity",
|
||||
"adequacy",
|
||||
"rating",
|
||||
"strategy",
|
||||
"extent",
|
||||
"distribution",
|
||||
"structure",
|
||||
"covering",
|
||||
"fire_rating",
|
||||
"external_decoration",
|
||||
"work_required",
|
||||
"age_band",
|
||||
"construction_type",
|
||||
"classification",
|
||||
"system",
|
||||
]);
|
||||
|
||||
export type AspectType = typeof aspectType.enumValues[number];
|
||||
19
src/app/db/schema/condition/element.ts
Normal file
19
src/app/db/schema/condition/element.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { bigint, bigserial, integer, pgTable } from "drizzle-orm/pg-core";
|
||||
import { propertyConditionSurvey } from "./property_condition_survey";
|
||||
import { elementType } from "./element_type";
|
||||
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
|
||||
|
||||
export const element = pgTable("element", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
|
||||
surveyId: bigint("survey_id", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => propertyConditionSurvey.id),
|
||||
|
||||
elementType: elementType("element_type").notNull(),
|
||||
elementInstance: integer("element_instance").notNull(),
|
||||
});
|
||||
|
||||
export type ElementRow = InferSelectModel<typeof element>;
|
||||
export type NewElementRow = InferInsertModel<typeof element>;
|
||||
|
||||
206
src/app/db/schema/condition/element_type.ts
Normal file
206
src/app/db/schema/condition/element_type.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { pgEnum } from "drizzle-orm/pg-core";
|
||||
|
||||
export const elementType = pgEnum("element_type", [
|
||||
"property",
|
||||
"property_construction_type",
|
||||
"property_classification",
|
||||
"property_age_band",
|
||||
"storey_count",
|
||||
"floor_level",
|
||||
"floor_level_front_door",
|
||||
"accessible_housing_register",
|
||||
"asbestos",
|
||||
"quality_standard",
|
||||
"ccu",
|
||||
"passenger_lift",
|
||||
"stairlift",
|
||||
"disabled_hoist_tracking",
|
||||
"disabled_facilities",
|
||||
"steps_to_front_door",
|
||||
"roof",
|
||||
"pitched_roof_covering",
|
||||
"flat_roof_covering",
|
||||
"rainwater_goods",
|
||||
"loft_insulation",
|
||||
"porch_canopy",
|
||||
"chimney",
|
||||
"fascia",
|
||||
"soffit",
|
||||
"fascia_soffit_bargeboards",
|
||||
"gutters",
|
||||
"store_roof",
|
||||
"garage_roof",
|
||||
"garage_and_store_roof",
|
||||
"external_wall",
|
||||
"external_noise_insulation",
|
||||
"primary_wall",
|
||||
"secondary_wall",
|
||||
"downpipes",
|
||||
"external_decoration",
|
||||
"cladding",
|
||||
"spandrel_panels",
|
||||
"garage_walls",
|
||||
"party_wall_fire_break",
|
||||
"external_brickwork_pointing",
|
||||
"internal_downpipes_external_area",
|
||||
"external_windows",
|
||||
"communal_windows",
|
||||
"secondary_glazing",
|
||||
"store_windows",
|
||||
"garage_windows",
|
||||
"garage_and_store_windows",
|
||||
"external_door",
|
||||
"front_door",
|
||||
"rear_door",
|
||||
"store_door",
|
||||
"garage_door",
|
||||
"garage_and_store_door",
|
||||
"communal_entrance_door",
|
||||
"main_door",
|
||||
"block_entrance_door",
|
||||
"lintel",
|
||||
"patio_french_door",
|
||||
"door_entry_handset",
|
||||
"paths_and_hardstandings",
|
||||
"parking_areas",
|
||||
"boundary_walls",
|
||||
"front_fencing",
|
||||
"rear_fencing",
|
||||
"side_fencing",
|
||||
"rear_gate",
|
||||
"front_gate",
|
||||
"gates",
|
||||
"retaining_walls",
|
||||
"private_balcony",
|
||||
"balcony_balustrade",
|
||||
"outbuildings",
|
||||
"garage_structure",
|
||||
"paving",
|
||||
"roads",
|
||||
"soil_and_vent",
|
||||
"solar_thermals",
|
||||
"drop_kerb",
|
||||
"outbuilding_overhaul",
|
||||
"external_structural_defects",
|
||||
"access_ramp",
|
||||
"kitchen",
|
||||
"kitchen_space_layout",
|
||||
"tenant_installed_kitchen",
|
||||
"kitchen_extractor_fan",
|
||||
"bathroom",
|
||||
"secondary_bathroom",
|
||||
"secondary_toilet",
|
||||
"bathroom_extractor_fan",
|
||||
"additional_wc_or_whb",
|
||||
"bathroom_remaining_life_source",
|
||||
"kitchen_remaining_life_source",
|
||||
"central_heating",
|
||||
"heating_boiler",
|
||||
"heating_distribution",
|
||||
"secondary_heating",
|
||||
"hot_water_system",
|
||||
"cold_water_storage",
|
||||
"heating_system",
|
||||
"boiler_fuel",
|
||||
"water_heating",
|
||||
"programmable_heating",
|
||||
"community_heating",
|
||||
"gas_available",
|
||||
"heat_recovery_units",
|
||||
"heating_improvements",
|
||||
"electrical_wiring",
|
||||
"consumer_unit",
|
||||
"smoke_detection",
|
||||
"heat_detection",
|
||||
"carbon_monoxide_detection",
|
||||
"fire_door_rating",
|
||||
"fire_risk_assessment",
|
||||
"internal_wiring",
|
||||
"electrics",
|
||||
"communal_heating",
|
||||
"communal_boiler",
|
||||
"communal_electrics",
|
||||
"communal_fire_alarm",
|
||||
"communal_emergency_lighting",
|
||||
"communal_door_entry",
|
||||
"communal_cctv",
|
||||
"communal_bin_store",
|
||||
"communal_bin_store_doors",
|
||||
"communal_bin_store_walls",
|
||||
"communal_bin_store_roof",
|
||||
"communal_refuse_chute",
|
||||
"communal_floor_covering",
|
||||
"communal_kitchen",
|
||||
"communal_bathroom",
|
||||
"communal_toilets",
|
||||
"communal_gates",
|
||||
"communal_lift",
|
||||
"communal_passenger_lift",
|
||||
"communal_balcony_walkway",
|
||||
"communal_entrance",
|
||||
"communal_internal_decorations",
|
||||
"communal_internal_floor",
|
||||
"communal_walkways",
|
||||
"communal_external_doors",
|
||||
"communal_stairs",
|
||||
"communal_aerial",
|
||||
"communal_aov",
|
||||
"communal_internal_doors",
|
||||
"communal_lateral_mains",
|
||||
"communal_lighting",
|
||||
"communal_lighting_conductor",
|
||||
"communal_store_roof",
|
||||
"communal_store_walls",
|
||||
"communal_store_doors",
|
||||
"communal_warden_call_system",
|
||||
"communal_bms",
|
||||
"communal_booster_pump",
|
||||
"communal_dry_riser",
|
||||
"communal_wet_riser",
|
||||
"communal_cold_water_storage",
|
||||
"communal_sprinkler",
|
||||
"communal_plug_sockets",
|
||||
"communal_circulation_space",
|
||||
"ffhh_damp",
|
||||
"ffhh_hold_and_cold_water",
|
||||
"ffhh_drainage_lavatories",
|
||||
"ffhh_neglected",
|
||||
"ffhh_natural_light",
|
||||
"ffhh_ventilation",
|
||||
"ffhh_food_prep_and_washup",
|
||||
"ffhh_unsafe_layout",
|
||||
"ffhh_unstable_building",
|
||||
"hhsrs_damp_and_mould",
|
||||
"hhsrs_excess_cold",
|
||||
"hhsrs_excess_heat",
|
||||
"hhsrs_asbestos_and_mmf",
|
||||
"hhsrs_biocides",
|
||||
"hhsrs_carbon_monoxide",
|
||||
"hhsrs_lead",
|
||||
"hhsrs_radiation",
|
||||
"hhsrs_uncombusted_fuel_gas",
|
||||
"hhsrs_volatile_organic_compounds",
|
||||
"hhsrs_crowding_and_space",
|
||||
"hhsrs_entry_by_intruders",
|
||||
"hhsrs_lighting",
|
||||
"hhsrs_noise",
|
||||
"hhsrs_domestic_hygiene_pests_refuse",
|
||||
"hhsrs_food_safety",
|
||||
"hhsrs_personal_hygiene_sanitation",
|
||||
"hhsrs_water_supply",
|
||||
"hhsrs_falls_associated_with_baths",
|
||||
"hhsrs_falls_on_level_surfaces",
|
||||
"hhsrs_falls_on_stairs",
|
||||
"hhsrs_falls_between_levels",
|
||||
"hhsrs_electrical_hazards",
|
||||
"hhsrs_fire",
|
||||
"hhsrs_flames_hot_surfaces",
|
||||
"hhsrs_collision_and_entrapment",
|
||||
"hhsrs_collision_hazards_low_headroom",
|
||||
"hhsrs_explosions",
|
||||
"hhsrs_ergonomics",
|
||||
"hhsrs_structural_collapse",
|
||||
"hhsrs_amenities",
|
||||
]);
|
||||
|
||||
export type ElementType = typeof elementType.enumValues[number];
|
||||
23
src/app/db/schema/condition/property_condition_survey.ts
Normal file
23
src/app/db/schema/condition/property_condition_survey.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
bigserial,
|
||||
bigint,
|
||||
date,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const propertyConditionSurvey = pgTable("property_condition_survey", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
|
||||
uprn: bigint("uprn", { mode: "number" }).notNull(),
|
||||
|
||||
date: date("date").notNull(),
|
||||
source: text("source").notNull(),
|
||||
});
|
||||
|
||||
export type PropertyConditionSurveyRow =
|
||||
InferSelectModel<typeof propertyConditionSurvey>;
|
||||
|
||||
export type NewPropertyConditionSurveyRow =
|
||||
InferInsertModel<typeof propertyConditionSurvey>;
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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={scenarioBusy}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 don’t 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ const updateSettings = async ({
|
|||
userId: userId.toString(),
|
||||
action: "update",
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const permissionsData = await permissionsReponse.json();
|
||||
|
|
@ -181,7 +181,7 @@ async function deletePortfolio({
|
|||
userId: userId.toString(),
|
||||
action: "delete",
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const permissionsData = await permissionsReponse.json();
|
||||
|
|
@ -201,7 +201,7 @@ async function deletePortfolio({
|
|||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
"deletePortfolio has been called into action but utterly failed to do the API handoff"
|
||||
"deletePortfolio has been called into action but utterly failed to do the API handoff",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -241,13 +241,13 @@ export default function PortfolioSettings({
|
|||
onError: (error) => {
|
||||
console.error(
|
||||
"Because the API hand off failed, we're right back here at the mutation station",
|
||||
error
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [portfolioName, setPortfolioName] = useState(
|
||||
portfolioSettingsData.name
|
||||
portfolioSettingsData.name,
|
||||
);
|
||||
|
||||
const [portfolioBudget, setPortfolioBudget] = useState<
|
||||
|
|
@ -255,11 +255,11 @@ export default function PortfolioSettings({
|
|||
>(portfolioSettingsData.budget);
|
||||
|
||||
const [portfolioGoal, setPortfolioGoal] = useState(
|
||||
portfolioSettingsData.goal
|
||||
portfolioSettingsData.goal,
|
||||
);
|
||||
|
||||
const [portfolioStatus, setPortfolioStatus] = useState(
|
||||
portfolioSettingsData.status
|
||||
portfolioSettingsData.status,
|
||||
);
|
||||
|
||||
// Set up state for deleteModal and deleteConfirmation
|
||||
|
|
@ -268,9 +268,13 @@ export default function PortfolioSettings({
|
|||
|
||||
const [deleteConfirmationByName, setDeleteConfirmationByName] = useState("");
|
||||
|
||||
if (session.status === "loading") {
|
||||
// You can return a loading spinner or placeholder here
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!session.data) {
|
||||
// The user is not logged in, redirect them to sign in
|
||||
router.push("/");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -473,7 +477,14 @@ export default function PortfolioSettings({
|
|||
<UsersPermissionsCard portfolioId={portfolioId} />
|
||||
<div className="rounded-md border border-red-500 mt-2">
|
||||
<Table>
|
||||
<TableHead className="text-lg text-brandblue">Danger Zone:</TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead colSpan={2} className="text-lg text-brandblue">
|
||||
Danger Zone:
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead className="text-brandblue">
|
||||
|
|
@ -483,6 +494,7 @@ export default function PortfolioSettings({
|
|||
assigned to this portfolio
|
||||
</p>
|
||||
</TableHead>
|
||||
|
||||
<TableCell className="flex justify-end">
|
||||
<Button
|
||||
className="bg-red-700 w-42"
|
||||
|
|
|
|||
|
|
@ -123,47 +123,35 @@ function useCreateRemoteAssessment({
|
|||
|
||||
const { assetListFileKey, valuationDataFileKey } = useMemo(
|
||||
() => generateS3Keys(userId, portfolioId),
|
||||
[userId, portfolioId]
|
||||
[userId, portfolioId],
|
||||
);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadCsvToS3,
|
||||
});
|
||||
const uploadFileMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
csvFile,
|
||||
}: {
|
||||
userId: string;
|
||||
portfolioId: string;
|
||||
fileKey: string;
|
||||
csvFile: Blob;
|
||||
}) => {
|
||||
// 1) Get presigned URL
|
||||
const { url } = await generatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const presignedMutation = useMutation({
|
||||
mutationFn: generatePresignedUrl,
|
||||
onSuccess: (data) => {
|
||||
let csvFile: Blob;
|
||||
if (data.fileKey === assetListFileKey) {
|
||||
const assetList: {
|
||||
uprn: number | null | undefined;
|
||||
address: string;
|
||||
postcode: string;
|
||||
property_type?: string;
|
||||
built_form?: string;
|
||||
}[] = [
|
||||
{
|
||||
uprn,
|
||||
address: addressLineOne,
|
||||
postcode,
|
||||
},
|
||||
];
|
||||
// 2) Upload to S3
|
||||
await uploadCsvToS3({
|
||||
presignedUrl: url,
|
||||
file: csvFile,
|
||||
});
|
||||
|
||||
// if we have property type and built form, include them. Handle typescript optionality
|
||||
if (propertyType) {
|
||||
assetList[0]["property_type"] = propertyType;
|
||||
}
|
||||
if (builtForm) {
|
||||
assetList[0]["built_form"] = builtForm;
|
||||
}
|
||||
|
||||
csvFile = new Blob([convertToCSV(assetList)], { type: "text/csv" });
|
||||
} else {
|
||||
const valuationData = [{ uprn, valuation }];
|
||||
csvFile = new Blob([convertToCSV(valuationData)], { type: "text/csv" });
|
||||
}
|
||||
|
||||
uploadMutation.mutate({ file: csvFile, presignedUrl: data.url });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -196,27 +184,51 @@ function useCreateRemoteAssessment({
|
|||
}
|
||||
|
||||
async function handleSubmit(formData: RemoteAssessmentFormValues) {
|
||||
// Build asset CSV
|
||||
const assetList = [
|
||||
{
|
||||
uprn,
|
||||
address: addressLineOne,
|
||||
postcode,
|
||||
...(propertyType && { property_type: propertyType }),
|
||||
...(builtForm && { built_form: builtForm }),
|
||||
},
|
||||
];
|
||||
|
||||
const assetCsv = new Blob([convertToCSV(assetList)], {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
// Build valuation CSV
|
||||
const valuationCsv = new Blob([convertToCSV([{ uprn, valuation }])], {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
// Upload asset list and valuation data and wait
|
||||
await Promise.all([
|
||||
presignedMutation.mutateAsync({
|
||||
uploadFileMutation.mutateAsync({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: assetListFileKey,
|
||||
csvFile: assetCsv,
|
||||
}),
|
||||
presignedMutation.mutateAsync({
|
||||
uploadFileMutation.mutateAsync({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: valuationDataFileKey,
|
||||
csvFile: valuationCsv,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Now trigger engine (files guaranteed in S3)
|
||||
await triggerEngine(formData);
|
||||
}
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
triggerEngine,
|
||||
isUploading: uploadMutation.isLoading || presignedMutation.isLoading,
|
||||
hasError: uploadMutation.isError || presignedMutation.isError,
|
||||
isUploading: uploadFileMutation.isPending,
|
||||
hasError: uploadFileMutation.isError,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function middleware(req: NextRequest) {
|
|||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Protect only your app’s authenticated areas
|
||||
// Protect only app’s authenticated areas
|
||||
"/home/:path*",
|
||||
"/portfolio/:path*",
|
||||
"/search/:path*",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue