revamping the plan page

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-10 21:54:26 +00:00
parent 2a51eae7ec
commit 2341741270
5 changed files with 960 additions and 609 deletions

View file

@ -6,6 +6,10 @@ const nextConfig = {
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
allowedDevOrigins: ['local-origin.dev', '*.local-origin.dev'],

View file

@ -1,232 +1,408 @@
"use client";
import {
Recommendation,
RecommendationType,
} from "@/app/db/schema/recommendations";
import { Dispatch, SetStateAction, useState } from "react";
import { useState } from "react";
import { RecommendationType } from "@/app/db/schema/recommendations";
import { formatNumber } from "@/app/utils";
import { RecommendationMetricMap } from "@/types/recommendations";
import RecommendationModal from "./RecommendationModal";
import {
ChevronDownIcon,
BuildingOfficeIcon,
HomeModernIcon,
Square3Stack3DIcon,
WindowIcon,
SunIcon,
FireIcon,
BeakerIcon,
ArrowPathIcon,
AdjustmentsHorizontalIcon,
LightBulbIcon,
WrenchScrewdriverIcon,
ArrowsRightLeftIcon,
} from "@heroicons/react/24/outline";
import AlternativesDrawer from "./AlternativesDrawer";
import type { AugmentedRec } from "./recommendation-types";
const selectionStyling =
"shadow active:shadow active:bg-brandmidblue w-full border rounded p-4 cursor-pointer text-gray-900 bg-gray-100 hover:bg-hoverblue hover:text-gray-100 transition-colors rounded-md flex flex-col justify-start";
const noSelectionStyling =
"shadow active:shadow active:bg-brandmidblue w-full border rounded p-4 cursor-pointer text-gray-300 bg-white hover:bg-hoverblue hover:text-gray-100 transition-colors rounded-md flex flex-col justify-start";
const alreadyInstalledStyling =
"shadow active:shadow w-full border rounded p-4 cursor-pointer text-gray-900 bg-gray-100transition-colors rounded-md flex flex-col justify-start";
export type { AugmentedRec } from "./recommendation-types";
const TitleMap = {
const TitleMap: Record<string, string> = {
mechanical_ventilation: "Mechanical Ventilation",
trickle_vents: "Trickle Vents",
sealing_open_fireplace: "Sealing Open Fireplace",
low_energy_lighting: "Low Energy Lighting",
// Walls
internal_wall_insulation: "Internal Wall Insulation",
external_wall_insulation: "External Wall Insulation",
cavity_wall_insulation: "Cavity Wall Insulation",
extension_cavity_wall_insulation: "Extension Cavity Wall Insulation",
// Roof
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",
exposed_floor_insulation: "Exposed Floor Insulation",
// Windows
windows_glazing: "Window Glazing",
mixed_glazing: "Mixed - Secondary and Double Glazing",
// Solar pv
solar_pv: "Solar Photovoltaic Panels System",
// Heating
heating: "Heating Systems",
mixed_glazing: "Mixed Glazing",
solar_pv: "Solar Photovoltaic Panels",
heating: "Heating System",
heating_control: "Heating Controls",
secondary_heating: "Secondary Heating System",
// Hot water tank
secondary_heating: "Secondary Heating",
hot_water_tank_insulation: "Hot Water Tank Insulation",
// Default options when no recommendation is selected
wall_insulation: "Wall Insulation",
floor_insulation: "Floor Insulation",
roof_insulation: "Roof Insulation",
// Cylinder thermostat
cylinder_thermostat: "Cylinder Thermostat",
// Draught proofing
draught_proofing: "Draught Proofing",
};
type RecommendationCardProps = {
componentType: RecommendationType;
recommendationData: Recommendation[];
setCostMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
costMap: RecommendationMetricMap;
setTotalEstimatedCost: Dispatch<SetStateAction<number>>;
sapMap: RecommendationMetricMap;
setSapMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
setTotalSapPoints: Dispatch<SetStateAction<number>>;
currentSapPoints: number;
setExpectedEpcRating: Dispatch<SetStateAction<string>>;
setTotalLabourDays: Dispatch<SetStateAction<number>>;
labourDaysMap: RecommendationMetricMap;
setLabourDaysMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
setCo2SavingsMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
co2SavingsMap: RecommendationMetricMap;
setTotalCo2Savings: Dispatch<SetStateAction<number>>;
setEnergyCostSavingsMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
energyCostSavingsMap: RecommendationMetricMap;
setTotalEnergyCostSavings: Dispatch<SetStateAction<number>>;
setKwhSavingsMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
kwhSavingsMap: RecommendationMetricMap;
setTotalKwhSavings: Dispatch<SetStateAction<number>>;
// All categories use a consistent cool blue accent
function getCategoryAccentColor(_category: string): string {
return "#2563eb";
}
function CategoryIcon({ category, color }: { category: string; color: string }) {
const cls = "w-6 h-6";
const style = { color };
switch (category) {
case "wall_insulation":
case "internal_wall_insulation":
case "external_wall_insulation":
case "cavity_wall_insulation":
case "extension_cavity_wall_insulation":
return <BuildingOfficeIcon className={cls} style={style} />;
case "roof_insulation":
case "loft_insulation":
case "room_roof_insulation":
case "flat_roof_insulation":
case "sloping_ceiling_insulation":
return <HomeModernIcon className={cls} style={style} />;
case "floor_insulation":
case "suspended_floor_insulation":
case "solid_floor_insulation":
case "exposed_floor_insulation":
return <Square3Stack3DIcon className={cls} style={style} />;
case "windows_glazing":
case "mixed_glazing":
return <WindowIcon className={cls} style={style} />;
case "solar_pv":
return <SunIcon className={cls} style={style} />;
case "heating":
case "secondary_heating":
case "sealing_open_fireplace":
return <FireIcon className={cls} style={style} />;
case "hot_water_tank_insulation":
return <BeakerIcon className={cls} style={style} />;
case "mechanical_ventilation":
case "trickle_vents":
return <ArrowPathIcon className={cls} style={style} />;
case "heating_control":
case "cylinder_thermostat":
return <AdjustmentsHorizontalIcon className={cls} style={style} />;
case "low_energy_lighting":
return <LightBulbIcon className={cls} style={style} />;
default:
return <WrenchScrewdriverIcon className={cls} style={style} />;
}
}
type Props = {
category: RecommendationType;
recs: AugmentedRec[];
selected: AugmentedRec | null;
pendingSelection: AugmentedRec | null;
hasPendingChange: boolean;
isIncluded: boolean;
isPending: boolean;
onSelect: (rec: AugmentedRec) => void;
onRemove: () => void;
};
export default function RecommendationCard({
componentType,
recommendationData,
setCostMap,
costMap,
setTotalEstimatedCost,
sapMap,
setSapMap,
setTotalSapPoints,
currentSapPoints,
setExpectedEpcRating,
setTotalLabourDays,
labourDaysMap,
setLabourDaysMap,
setCo2SavingsMap,
co2SavingsMap,
setTotalCo2Savings,
setEnergyCostSavingsMap,
energyCostSavingsMap,
setTotalEnergyCostSavings,
setKwhSavingsMap,
kwhSavingsMap,
setTotalKwhSavings,
}: RecommendationCardProps) {
const defaultComponent = recommendationData.find(
(rec: Recommendation) => rec.default,
) as Recommendation;
category,
recs,
selected,
pendingSelection,
hasPendingChange,
isIncluded,
isPending,
onSelect,
onRemove,
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
// A recommendation type could have no default recommendation, so we need to check if it exists
const alreadyInstalled = recommendationData.some(
(rec) => rec.alreadyInstalled,
const alreadyInstalled = recs.some((r) => r.alreadyInstalled);
const title = TitleMap[category] ?? category.replace(/_/g, " ");
const isInteractive = !alreadyInstalled;
const accentColor = getCategoryAccentColor(category);
// Display pending selection in header chips if there's a pending change
const displayRec = hasPendingChange ? pendingSelection : selected;
// Display pending selection in the expanded grid too
const displaySelected = hasPendingChange ? pendingSelection : selected;
const alternatives = recs.filter(
(r) => !r.alreadyInstalled && r.id !== displaySelected?.id,
);
const [cardComponent, setCardComponent] =
useState<Recommendation>(defaultComponent);
const [modalIsOpen, setModalIsOpen] = useState(false);
const getTitle = () => {
if (!cardComponent) {
return TitleMap[componentType];
}
const recommendationType = cardComponent.type as RecommendationType;
return TitleMap[recommendationType];
};
// Determine the className based on alreadyInstalled or cardComponent existence
const cardClassName = alreadyInstalled
? alreadyInstalledStyling
: cardComponent
? selectionStyling
: noSelectionStyling;
const optionTextClassName = alreadyInstalled
? "text-brandgold"
: "text-brandbrown hover:text-blue-300";
const optionsText = alreadyInstalled
? "Already installed"
: cardComponent
? "Click for more options"
: "Click to select";
const openModal = () => {
// If the card is already installed, we don't want to open the modal
if (alreadyInstalled) {
return;
}
setModalIsOpen(true);
};
// If the measure is already installed, we change the font colour to gold
const titleClassName = alreadyInstalled
? "text-brandgold font-bold mb-4 text-lg"
: "font-bold mb-4 text-lg";
const hasMore = recs.filter((r) => !r.alreadyInstalled).length > 3;
return (
<div className={cardClassName} onClick={openModal}>
<h2 className={titleClassName}>{getTitle()}</h2>
<div className="mb-3">
{cardComponent ? (
cardComponent.description
) : (
<div className="text-red-500">No measure selected</div>
<div
className={`rounded-xl overflow-hidden bg-white shadow-[0_8px_30px_rgb(0,0,0,0.06)] hover:shadow-[0_12px_40px_rgb(0,0,0,0.10)] transition-all border ${
hasPendingChange ? "border-amber-300" : "border-gray-100"
} ${!isIncluded && !alreadyInstalled ? "opacity-60" : ""}`}
>
<div className="p-8">
{/* ── Card header ─────────────────────────────────────────── */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-6">
{/* Left: circular icon + title block */}
<div className="flex items-center gap-5 flex-1 min-w-0">
{/* Circular icon */}
<div
className={`w-14 h-14 rounded-full flex items-center justify-center shrink-0 ${alreadyInstalled ? "bg-amber-50" : ""}`}
style={
!alreadyInstalled
? { backgroundColor: `${accentColor}18` }
: undefined
}
>
{alreadyInstalled ? (
<CategoryIcon category={category} color="#d97706" />
) : (
<CategoryIcon category={category} color={accentColor} />
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-xl font-bold font-manrope text-brandblue leading-snug">
{title}
</h4>
{!isIncluded && !alreadyInstalled && (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-gray-100 text-gray-400 font-bold uppercase tracking-wide">
Not included
</span>
)}
</div>
{displayRec && !alreadyInstalled && (
<p className="text-sm text-gray-500 font-semibold mt-0.5 truncate">
{displayRec.description}
</p>
)}
{!displayRec && !alreadyInstalled && (
<p className="text-sm text-gray-400 mt-0.5">
No measure selected
</p>
)}
{alreadyInstalled && (
<span className="mt-1 inline-block px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-100 text-amber-700">
Already Installed
</span>
)}
</div>
</div>
{/* Right: stat chips + status indicators */}
<div className="flex items-center gap-3 shrink-0 flex-wrap justify-end">
{hasPendingChange && !isPending && (
<span className="text-[10px] font-bold text-amber-600 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse inline-block" />
Pending
</span>
)}
{isPending && (
<span className="text-xs font-bold text-gray-400 flex items-center gap-1">
<ArrowPathIcon className="w-4 h-4 animate-spin" />
Saving
</span>
)}
{displayRec?.sapPoints != null && displayRec.sapPoints > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg text-center border border-gray-100 min-w-[72px]">
<p className="text-[9px] font-bold uppercase tracking-widest mb-0.5 text-brandmidblue">
SAP Boost
</p>
<p className="text-sm font-black text-brandblue font-manrope">
+{displayRec.sapPoints.toFixed(1)}
</p>
</div>
)}
{displayRec?.energyCostSavings != null &&
displayRec.energyCostSavings > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg text-center border border-gray-100 min-w-[72px]">
<p className="text-[9px] font-bold text-brandmidblue uppercase tracking-widest mb-0.5">
Savings
</p>
<p className="text-sm font-black text-brandblue font-manrope">
£{formatNumber(displayRec.energyCostSavings)}/yr
</p>
</div>
)}
{displayRec?.estimatedCost != null && displayRec.estimatedCost > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg text-center border border-gray-100 min-w-[72px]">
<p className="text-[9px] font-bold text-brandmidblue uppercase tracking-widest mb-0.5">
Cost
</p>
<p className="text-sm font-black text-brandblue font-manrope">
£{formatNumber(displayRec.estimatedCost)}
</p>
</div>
)}
</div>
</div>
{/* ── Collapsed CTA ───────────────────────────────────────── */}
{isInteractive && !isOpen && (
<button
onClick={() => setIsOpen(true)}
className="w-full py-3 rounded-xl bg-gray-50 border border-gray-100 text-brandblue font-bold text-sm hover:bg-gray-100 transition-colors flex items-center justify-center gap-2"
>
<ArrowsRightLeftIcon className="w-4 h-4" style={{ color: accentColor }} />
{isIncluded
? "View alternative options considered"
: "Add to plan / view options"}
</button>
)}
{/* ── Expanded alternatives panel ─────────────────────────── */}
{isInteractive && isOpen && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className="text-[10px] font-black uppercase tracking-widest text-gray-400 whitespace-nowrap">
Detailed Alternatives
</span>
<div className="h-px flex-grow bg-gray-100" />
</div>
{/* Grid: chosen card + up to 2 alternatives */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{displaySelected && (
<div
className="p-5 rounded-xl"
style={{
border: `1px solid ${accentColor}30`,
backgroundColor: `${accentColor}08`,
}}
>
<div className="flex justify-between items-start mb-3">
<p className="font-bold text-sm text-brandblue leading-tight flex-1 mr-2">
{displaySelected.description}
</p>
<span
className="text-[10px] px-2 py-0.5 rounded font-bold uppercase whitespace-nowrap shrink-0 text-white"
style={{ backgroundColor: accentColor }}
>
{hasPendingChange ? "Pending" : "Chosen"}
</span>
</div>
{(displaySelected.sapPoints != null && displaySelected.sapPoints > 0) ||
(displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0) ? (
<div className="flex flex-wrap gap-1.5 mb-3">
{displaySelected.sapPoints != null && displaySelected.sapPoints > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-green-100 text-green-700">
+{displaySelected.sapPoints.toFixed(1)} SAP
</span>
)}
{displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-amber-100 text-amber-700">
£{formatNumber(displaySelected.energyCostSavings)}/yr saved
</span>
)}
</div>
) : null}
<p className="font-black text-sm text-brandblue">
£{formatNumber(displaySelected.estimatedCost ?? 0)}
</p>
</div>
)}
{alternatives.slice(0, displaySelected ? 2 : 3).map((alt) => (
<div
key={String(alt.id)}
className="p-5 rounded-xl border border-gray-100 bg-gray-50/50 flex flex-col justify-between"
>
<div>
<p className="font-bold text-sm text-brandblue leading-tight mb-3">
{alt.description}
</p>
{(alt.sapPoints != null && alt.sapPoints > 0) ||
(alt.energyCostSavings != null && alt.energyCostSavings > 0) ? (
<div className="flex flex-wrap gap-1.5 mb-3">
{alt.sapPoints != null && alt.sapPoints > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-green-100 text-green-700">
+{alt.sapPoints.toFixed(1)} SAP
</span>
)}
{alt.energyCostSavings != null && alt.energyCostSavings > 0 && (
<span className="text-xs font-bold px-2.5 py-1 rounded-full bg-amber-100 text-amber-700">
£{formatNumber(alt.energyCostSavings)}/yr saved
</span>
)}
</div>
) : null}
</div>
<div className="flex items-center justify-between mt-2">
<p className="font-black text-sm text-brandblue">
£{formatNumber(alt.estimatedCost ?? 0)}
</p>
<button
disabled={isPending}
onClick={() => onSelect(alt)}
className="px-3 py-1.5 text-xs font-bold rounded-lg bg-white hover:bg-gray-50 transition-colors disabled:opacity-40 border border-gray-200"
style={{ color: accentColor }}
>
Select
</button>
</div>
</div>
))}
</div>
{hasMore && (
<button
onClick={() => setIsDrawerOpen(true)}
className="w-full py-3 rounded-xl bg-gray-50 text-brandblue font-bold text-xs uppercase tracking-widest hover:bg-gray-100 transition-colors border border-gray-100"
>
Compare all {recs.filter((r) => !r.alreadyInstalled).length}{" "}
options
</button>
)}
{/* Footer actions */}
<div className="flex flex-col gap-2 pt-1">
{(selected || pendingSelection) && (
<button
disabled={isPending}
onClick={onRemove}
className="w-full py-3 rounded-xl border border-red-100 text-red-500 font-bold text-xs uppercase tracking-widest hover:bg-red-50 transition-colors disabled:opacity-40"
>
Remove this measure from plan
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="w-full py-3 rounded-xl bg-gray-50 text-gray-500 font-bold text-xs uppercase tracking-widest hover:bg-gray-100 transition-colors flex items-center justify-center gap-2"
>
<ChevronDownIcon className="w-4 h-4 rotate-180" />
Collapse alternative options
</button>
</div>
</div>
)}
</div>
<div className={optionTextClassName}>{optionsText}</div>
{cardComponent ? (
<table className="w-full text-left">
<tbody>
<tr>
<td className="font-medium">Estimated Cost:</td>
<td className="font-bold">
{cardComponent
? "£" + formatNumber(cardComponent?.estimatedCost || 0)
: ""}
</td>
</tr>
{cardComponent.sapPoints != null && (
<tr>
<td className="font-medium">SAP Points:</td>
<td className="font-bold">
{cardComponent.sapPoints < 0.1 &&
cardComponent.type !== "mechanical_ventilation"
? "Negligible"
: cardComponent.sapPoints}
</td>
</tr>
)}
</tbody>
</table>
) : (
""
)}
<RecommendationModal
title={componentType}
isOpen={modalIsOpen}
setIsOpen={setModalIsOpen}
recommendationData={recommendationData}
setCardComponent={setCardComponent}
setCostMap={setCostMap}
costMap={costMap}
setTotalEstimatedCost={setTotalEstimatedCost}
sapMap={sapMap}
setSapMap={setSapMap}
setTotalSapPoints={setTotalSapPoints}
currentSapPoints={currentSapPoints}
setExpectedEpcRating={setExpectedEpcRating}
// Labour
setTotalLabourDays={setTotalLabourDays}
labourDaysMap={labourDaysMap}
setLabourDaysMap={setLabourDaysMap}
// Co2
setCo2SavingsMap={setCo2SavingsMap}
co2SavingsMap={co2SavingsMap}
setTotalCo2Savings={setTotalCo2Savings}
// Energy Cost
setEnergyCostSavingsMap={setEnergyCostSavingsMap}
energyCostSavingsMap={energyCostSavingsMap}
setTotalEnergyCostSavings={setTotalEnergyCostSavings}
// kWh Savings
setKwhSavingsMap={setKwhSavingsMap}
kwhSavingsMap={kwhSavingsMap}
setTotalKwhSavings={setTotalKwhSavings}
<AlternativesDrawer
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
categoryLabel={title}
recs={recs}
selected={selected}
displaySelected={pendingSelection ?? selected}
onSelect={(rec) => {
onSelect(rec);
setIsDrawerOpen(false);
}}
isPending={isPending}
/>
</div>
);

View file

@ -1,441 +1,267 @@
"use client";
import { useState, useTransition, useMemo } from "react";
import {
Recommendation,
RecommendationType,
Plan,
} from "@/app/db/schema/recommendations";
import RecommendationCard from "./RecommendationCard";
import WorksPackageCard from "./WorksPackageCard";
import ValuationImpactComponent from "./ValuationImpactComponent";
import { Separator } from "@/app/shadcn_components/ui/separator";
import { PropertyMeta } from "@/app/db/schema/property";
import { sapToEpc } from "@/app/utils";
import { useState } from "react";
import { sumRecommendationMetricMap } from "@/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils";
import { RecommendationMetricMap } from "@/types/recommendations";
import {
EnergyEfficiencyImpactCard,
SecondaryEnergyEfficiencyImpactCard,
} from "./EnergyEfficiencyImpactCard";
import { FundingPackageWithMeasures } from "@/app/db/schema/funding";
import { InstalledMeasureSummary } from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
import {
setDefaultRecommendation,
clearCategoryDefault,
updatePlanMetrics,
} from "@/app/actions/recommendations";
import RecommendationCard, { AugmentedRec } from "./RecommendationCard";
import StickyImpactBar from "./StickyImpactBar";
import { sapToEpc } from "@/app/utils";
import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
interface RecommendationContainerProps {
recommendations: Recommendation[];
propertyMeta: PropertyMeta;
planMeta: Plan;
funding: FundingPackageWithMeasures[];
installedMeasures: InstalledMeasureSummary[];
}
export type { AugmentedRec };
const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } =
{
internal_wall_insulation: "wall_insulation",
external_wall_insulation: "wall_insulation",
cavity_wall_insulation: "wall_insulation",
extension_cavity_wall_insulation: "extension_cavity_wall_insulation",
loft_insulation: "roof_insulation",
room_roof_insulation: "roof_insulation",
flat_roof_insulation: "roof_insulation",
suspended_floor_insulation: "floor_insulation",
solid_floor_insulation: "floor_insulation",
exposed_floor_insulation: "floor_insulation",
windows_glazing: "windows_glazing",
heating: "heating",
};
const emptyImpactState = {
estimatedCost: 0,
sapPoints: 0,
labourDays: 0,
co2EquivalentSavings: 0,
energyCostSavings: 0,
kwhSavings: 0,
const typeToCategoryMap: Partial<
Record<RecommendationType, RecommendationType>
> = {
internal_wall_insulation: "wall_insulation",
external_wall_insulation: "wall_insulation",
cavity_wall_insulation: "wall_insulation",
extension_cavity_wall_insulation: "extension_cavity_wall_insulation",
loft_insulation: "roof_insulation",
room_roof_insulation: "roof_insulation",
flat_roof_insulation: "roof_insulation",
suspended_floor_insulation: "floor_insulation",
solid_floor_insulation: "floor_insulation",
exposed_floor_insulation: "floor_insulation",
windows_glazing: "windows_glazing",
heating: "heating",
};
interface Props {
recommendations: Recommendation[];
installedMeasures: InstalledMeasureSummary[];
planId: string;
slug: string;
propertyId: string;
currentSapPoints: number;
savedCostOfWorks: number;
savedContingencyCost: number;
}
export default function RecommendationContainer({
recommendations,
propertyMeta,
planMeta,
funding,
installedMeasures,
}: RecommendationContainerProps) {
// Get the unique types of installed measures for easy lookup
const installedMeasureTypeSet = new Set(
installedMeasures.map((m) => m.measureType)
);
const categorizedRecommendations = recommendations.reduce(
(acc, curr) => {
const typeKey = curr.type as RecommendationType;
const category = typeToCategoryMap[typeKey] ?? typeKey;
if (!acc[category]) {
acc[category] = [];
}
planId,
slug,
propertyId,
currentSapPoints,
savedCostOfWorks,
savedContingencyCost,
}: Props) {
const installedTypeSet = new Set(installedMeasures.map((m) => m.measureType));
const categorized = recommendations.reduce(
(acc, rec) => {
const category = (typeToCategoryMap[rec.type as RecommendationType] ??
rec.type) as RecommendationType;
const alreadyInstalled =
curr.measureType != null &&
installedMeasureTypeSet.has(curr.measureType);
acc[category].push({
...curr,
alreadyInstalled: alreadyInstalled,
sapPoints: alreadyInstalled ? 0 : curr.sapPoints,
estimatedCost: alreadyInstalled ? 0 : curr.estimatedCost,
co2EquivalentSavings: alreadyInstalled ? 0 : curr.co2EquivalentSavings,
energyCostSavings: alreadyInstalled ? 0 : curr.energyCostSavings,
kwhSavings: alreadyInstalled ? 0 : curr.kwhSavings,
labourDays: alreadyInstalled ? 0 : curr.labourDays,
});
rec.measureType != null && installedTypeSet.has(rec.measureType);
const augmented: AugmentedRec = {
...rec,
alreadyInstalled,
sapPoints: alreadyInstalled ? 0 : rec.sapPoints,
estimatedCost: alreadyInstalled ? 0 : rec.estimatedCost,
co2EquivalentSavings: alreadyInstalled ? 0 : rec.co2EquivalentSavings,
energyCostSavings: alreadyInstalled ? 0 : rec.energyCostSavings,
kwhSavings: alreadyInstalled ? 0 : rec.kwhSavings,
labourDays: alreadyInstalled ? 0 : rec.labourDays,
};
if (!acc[category]) acc[category] = [];
acc[category].push(augmented);
return acc;
},
{} as Record<
RecommendationType,
(Recommendation & { alreadyInstalled: boolean })[]
>
{} as Record<RecommendationType, AugmentedRec[]>,
);
const defaultWallsRecommendations =
categorizedRecommendations.wall_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultFloorRecommendations =
categorizedRecommendations.floor_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultRoofRecommendations =
categorizedRecommendations.roof_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultVentiliationRecommendations =
categorizedRecommendations.mechanical_ventilation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultFireplaceRecommendations =
categorizedRecommendations.sealing_open_fireplace?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultLightingRecommendations =
categorizedRecommendations.low_energy_lighting?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultWindowsRecommendations =
categorizedRecommendations.windows_glazing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultSolarRecommendations =
categorizedRecommendations.solar_pv?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultHeatingRecommendations =
categorizedRecommendations.heating?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultHeatingControlRecommendations =
categorizedRecommendations.heating_control?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultHotWaterTankRecommendations =
categorizedRecommendations.hot_water_tank_insulation?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultSecondaryHeatingRecommendations =
categorizedRecommendations.secondary_heating?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultCylinderThermostatRecommendations =
categorizedRecommendations.cylinder_thermostat?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultTrickleVentsRecommendations =
categorizedRecommendations.trickle_vents?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultMixedGlazingRecommendations =
categorizedRecommendations.mixed_glazing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultDraughtProofingRecommendations =
categorizedRecommendations.draught_proofing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const [costMap, setCostMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.estimatedCost || 0,
floor_insulation: defaultFloorRecommendations.estimatedCost || 0,
roof_insulation: defaultRoofRecommendations.estimatedCost || 0,
mechanical_ventilation:
defaultVentiliationRecommendations.estimatedCost || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.estimatedCost || 0,
low_energy_lighting: defaultLightingRecommendations.estimatedCost || 0,
windows_glazing: defaultWindowsRecommendations.estimatedCost || 0,
solar_pv: defaultSolarRecommendations.estimatedCost || 0,
heating: defaultHeatingRecommendations.estimatedCost || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.estimatedCost || 0,
heating_control: defaultHeatingControlRecommendations.estimatedCost || 0,
secondary_heating:
defaultSecondaryHeatingRecommendations.estimatedCost || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.estimatedCost || 0,
trickle_vents: defaultTrickleVentsRecommendations.estimatedCost || 0,
mixed_glazing: defaultMixedGlazingRecommendations.estimatedCost || 0,
draught_proofing: defaultDraughtProofingRecommendations.estimatedCost || 0,
});
const [sapMap, setSapMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.sapPoints || 0,
floor_insulation: defaultFloorRecommendations.sapPoints || 0,
roof_insulation: defaultRoofRecommendations.sapPoints || 0,
mechanical_ventilation: defaultVentiliationRecommendations.sapPoints || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.sapPoints || 0,
low_energy_lighting: defaultLightingRecommendations.sapPoints || 0,
windows_glazing: defaultWindowsRecommendations.sapPoints || 0,
solar_pv: defaultSolarRecommendations.sapPoints || 0,
heating: defaultHeatingRecommendations.sapPoints || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.sapPoints || 0,
heating_control: defaultHeatingControlRecommendations.sapPoints || 0,
secondary_heating: defaultSecondaryHeatingRecommendations.sapPoints || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.sapPoints || 0,
trickle_vents: defaultTrickleVentsRecommendations.sapPoints || 0,
mixed_glazing: defaultMixedGlazingRecommendations.sapPoints || 0,
draught_proofing: defaultDraughtProofingRecommendations.sapPoints || 0,
});
const [labourDaysMap, setLabourDaysMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.labourDays || 0,
floor_insulation: defaultFloorRecommendations.labourDays || 0,
roof_insulation: defaultRoofRecommendations.labourDays || 0,
mechanical_ventilation: defaultVentiliationRecommendations.labourDays || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.labourDays || 0,
low_energy_lighting: defaultLightingRecommendations.labourDays || 0,
windows_glazing: defaultWindowsRecommendations.labourDays || 0,
solar_pv: defaultSolarRecommendations.labourDays || 0,
heating: defaultHeatingRecommendations.labourDays || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.labourDays || 0,
heating_control: defaultHeatingControlRecommendations.labourDays || 0,
secondary_heating: defaultSecondaryHeatingRecommendations.labourDays || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.labourDays || 0,
trickle_vents: defaultTrickleVentsRecommendations.labourDays || 0,
mixed_glazing: defaultMixedGlazingRecommendations.labourDays || 0,
draught_proofing: defaultDraughtProofingRecommendations.labourDays || 0,
});
const [co2SavingsMap, setCo2SavingsMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.co2EquivalentSavings || 0,
floor_insulation: defaultFloorRecommendations.co2EquivalentSavings || 0,
roof_insulation: defaultRoofRecommendations.co2EquivalentSavings || 0,
mechanical_ventilation:
defaultVentiliationRecommendations.co2EquivalentSavings || 0,
sealing_open_fireplace:
defaultFireplaceRecommendations.co2EquivalentSavings || 0,
low_energy_lighting:
defaultLightingRecommendations.co2EquivalentSavings || 0,
windows_glazing: defaultWindowsRecommendations.co2EquivalentSavings || 0,
solar_pv: defaultSolarRecommendations.co2EquivalentSavings || 0,
heating: defaultHeatingRecommendations.co2EquivalentSavings || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.co2EquivalentSavings || 0,
heating_control:
defaultHeatingControlRecommendations.co2EquivalentSavings || 0,
secondary_heating:
defaultSecondaryHeatingRecommendations.co2EquivalentSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.co2EquivalentSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.co2EquivalentSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.co2EquivalentSavings || 0,
draught_proofing:
defaultDraughtProofingRecommendations.co2EquivalentSavings || 0,
});
const [energyCostSavingsMap, setEnergyCostSavingsMap] =
useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations?.energyCostSavings || 0,
floor_insulation: defaultFloorRecommendations.energyCostSavings || 0,
roof_insulation: defaultRoofRecommendations.energyCostSavings || 0,
mechanical_ventilation:
defaultVentiliationRecommendations.energyCostSavings || 0,
sealing_open_fireplace:
defaultFireplaceRecommendations.energyCostSavings || 0,
low_energy_lighting:
defaultLightingRecommendations.energyCostSavings || 0,
windows_glazing: defaultWindowsRecommendations.energyCostSavings || 0,
solar_pv: defaultSolarRecommendations.energyCostSavings || 0,
heating: defaultHeatingRecommendations.energyCostSavings || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.energyCostSavings || 0,
heating_control:
defaultHeatingControlRecommendations.energyCostSavings || 0,
secondary_heating:
defaultSecondaryHeatingRecommendations.energyCostSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.energyCostSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.energyCostSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.energyCostSavings || 0,
draught_proofing:
defaultDraughtProofingRecommendations.energyCostSavings || 0,
});
const [kwhSavingsMap, setKwhSavingsMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.kwhSavings || 0,
floor_insulation: defaultFloorRecommendations.kwhSavings || 0,
roof_insulation: defaultRoofRecommendations.kwhSavings || 0,
mechanical_ventilation: defaultVentiliationRecommendations.kwhSavings || 0,
sealing_open_fireplace: defaultFireplaceRecommendations.kwhSavings || 0,
low_energy_lighting: defaultLightingRecommendations.kwhSavings || 0,
windows_glazing: defaultWindowsRecommendations.kwhSavings || 0,
solar_pv: defaultSolarRecommendations.kwhSavings || 0,
heating: defaultHeatingRecommendations.kwhSavings || 0,
hot_water_tank_insulation:
defaultHotWaterTankRecommendations.kwhSavings || 0,
heating_control: defaultHeatingControlRecommendations.kwhSavings || 0,
secondary_heating: defaultSecondaryHeatingRecommendations.kwhSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.kwhSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.kwhSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.kwhSavings || 0,
draught_proofing: defaultDraughtProofingRecommendations.kwhSavings || 0,
});
const [totalEstimatedCost, setTotalEstimatedCost] = useState(
sumRecommendationMetricMap(costMap)
const [selections, setSelections] = useState<
Record<string, AugmentedRec | null>
>(() =>
Object.fromEntries(
Object.entries(categorized).map(([cat, recs]) => [
cat,
recs.find((r) => r.default) ?? null,
]),
),
);
const [totalSapPoints, setTotalSapPoints] = useState(
sumRecommendationMetricMap(sapMap)
// Pending selections — changes not yet saved to the database
const [pendingSelections, setPendingSelections] = useState<
Record<string, AugmentedRec | null>
>({});
const [isPending, startTransition] = useTransition();
// Sort: included first, then not-included, then already-installed
const sortedCategories = Object.entries(categorized).sort(
([catA, recsA], [catB, recsB]) => {
const score = (cat: string, recs: AugmentedRec[]) => {
if (recs.some((r) => r.alreadyInstalled)) return 2;
if (selections[cat]) return 0;
return 1;
};
return score(catA, recsA) - score(catB, recsB);
},
);
const [totalLabourDays, setTotalLabourDays] = useState(
sumRecommendationMetricMap(labourDaysMap)
);
const [totalCo2Savings, setTotalCo2Savings] = useState(
sumRecommendationMetricMap(co2SavingsMap)
);
const [totalEnergyCostSavings, setTotalEnergyCostSavings] = useState(
sumRecommendationMetricMap(energyCostSavingsMap)
);
const [totalKwhSavings, setTotalKwhSavings] = useState(
sumRecommendationMetricMap(kwhSavingsMap)
);
// for the moment, we shouldn't have more than one funding package and so we flag if we have more than one
if (funding.length > 1) {
console.warn("Multiple funding packages found, using the first one.");
// Only stages a pending change — does NOT call server actions
function handleSelect(category: string, rec: AugmentedRec) {
setPendingSelections((prev) => ({ ...prev, [category]: rec }));
}
// Sum up project funding and uplift
const [totalFunding, setTotalFunding] = useState(
funding[0]
? (funding[0].projectFunding ?? 0) + (funding[0].totalUplift ?? 0)
: 0
);
// Stages a removal as a pending change — does NOT call server actions
function handleRemove(category: string) {
setPendingSelections((prev) => ({ ...prev, [category]: null }));
}
const currentEpcRating = propertyMeta.currentEpcRating;
const currentSapPoints = propertyMeta.currentSapPoints;
// Discard all pending changes
function handleDiscardAll() {
setPendingSelections({});
}
const expectedSapPoints = Math.min(currentSapPoints + totalSapPoints, 100);
const [expectedEpcRating, setExpectedEpcRating] = useState(
sapToEpc(expectedSapPoints)
);
// Save all pending changes to the database
function handleSaveAll() {
const snapshot = { ...pendingSelections };
setSelections((prev) => {
const merged = { ...prev };
for (const [cat, rec] of Object.entries(snapshot)) {
merged[cat] = rec;
}
return merged;
});
setPendingSelections({});
startTransition(async () => {
await Promise.all(
Object.entries(snapshot).map(([cat, rec]) => {
if (rec) {
return setDefaultRecommendation(
planId,
String(rec.id),
slug,
propertyId,
{ skipRevalidate: true },
);
} else {
const anyRec = categorized[cat as RecommendationType]?.[0];
if (!anyRec) return Promise.resolve();
return clearCategoryDefault(planId, anyRec.type, slug, propertyId, {
skipRevalidate: true,
});
}
}),
);
await updatePlanMetrics(planId, currentSapPoints, slug, propertyId);
});
}
// Projected metrics: merge pending on top of committed, then sum
const projectedMetrics = useMemo(() => {
const effectiveRecs = Object.keys(categorized)
.map((cat) =>
cat in pendingSelections ? pendingSelections[cat] : selections[cat],
)
.filter((r): r is AugmentedRec => r != null);
const sapGain = effectiveRecs.reduce((s, r) => s + (r.sapPoints ?? 0), 0);
const projectedSap = currentSapPoints + sapGain;
const projectedCost = effectiveRecs.reduce(
(s, r) => s + (r.estimatedCost ?? 0),
0,
);
// Scale contingency proportionally from saved plan values
const contingencyRate = savedCostOfWorks > 0 ? savedContingencyCost / savedCostOfWorks : 0;
const projectedContingency = Math.round(projectedCost * contingencyRate);
return {
projectedSap,
projectedEpc: sapToEpc(projectedSap),
projectedCo2: effectiveRecs.reduce(
(s, r) => s + (r.co2EquivalentSavings ?? 0),
0,
),
projectedHeatDemand: effectiveRecs.reduce(
(s, r) => s + (r.heatDemand ?? 0),
0,
),
projectedCost,
projectedContingency,
};
}, [pendingSelections, selections, categorized, currentSapPoints, savedCostOfWorks, savedContingencyCost]);
// Current saved baseline derived from committed selections
const savedMetrics = useMemo(() => {
const committed = Object.values(selections).filter(
(r): r is AugmentedRec => r != null,
);
const sapGain = committed.reduce((s, r) => s + (r.sapPoints ?? 0), 0);
const savedSap = currentSapPoints + sapGain;
return { savedSap, savedEpc: sapToEpc(savedSap) };
}, [selections, currentSapPoints]);
const hasPendingChanges = Object.keys(pendingSelections).length > 0;
const pendingCount = Object.keys(pendingSelections).length;
if (Object.keys(categorized).length === 0) {
return (
<div className="bg-white rounded-2xl border border-dashed border-gray-200 p-16 flex flex-col items-center justify-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gray-50 border border-gray-200 flex items-center justify-center">
<WrenchScrewdriverIcon className="w-7 h-7 text-gray-400" />
</div>
<p className="text-sm text-gray-400">
No recommendations for this plan.
</p>
</div>
);
}
return (
<>
<div className="mt-8 mb-4 flex-col grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch">
<WorksPackageCard
totalEstimatedCost={totalEstimatedCost}
totalLabourDays={totalLabourDays}
totalFunding={totalFunding}
/>
<EnergyEfficiencyImpactCard
currentEpcRating={currentEpcRating}
expectedEpcRating={expectedEpcRating}
currentSapPoints={currentSapPoints}
expectedSapPoints={expectedSapPoints}
totalSapPoints={totalSapPoints}
/>
<SecondaryEnergyEfficiencyImpactCard
TotalCo2Savings={totalCo2Savings}
totalEnergyCostSavings={totalEnergyCostSavings}
totalKwhSavings={totalKwhSavings}
/>
<ValuationImpactComponent
currentValuation={propertyMeta.currentValuation}
valuationIncreaseLowerBound={planMeta.valuationIncreaseLowerBound}
valuationIncreaseUpperBound={planMeta.valuationIncreaseUpperBound}
funding={funding[0]}
/>
<div className="space-y-10 pb-24">
{sortedCategories.map(([category, recs]) => {
const isIncluded =
!!selections[category] && !recs.some((r) => r.alreadyInstalled);
return (
<RecommendationCard
key={category}
category={category as RecommendationType}
recs={recs}
selected={selections[category] ?? null}
pendingSelection={pendingSelections[category] ?? null}
hasPendingChange={category in pendingSelections}
isIncluded={isIncluded}
isPending={isPending}
onSelect={(rec) => handleSelect(category, rec)}
onRemove={() => handleRemove(category)}
/>
);
})}
</div>
<Separator className="mb-4 bg-brandbrown" />
<div className="flex-col grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch">
{Object.entries(categorizedRecommendations).map(
([componentType, recommendationData], idx) => {
return (
<RecommendationCard
key={idx}
// entires means we loose the typing on the key
componentType={componentType as RecommendationType}
recommendationData={recommendationData}
// cost
setCostMap={setCostMap}
costMap={costMap}
setTotalEstimatedCost={setTotalEstimatedCost}
// Sap
setSapMap={setSapMap}
sapMap={sapMap}
setTotalSapPoints={setTotalSapPoints}
currentSapPoints={currentSapPoints}
setExpectedEpcRating={setExpectedEpcRating}
// Labour
setTotalLabourDays={setTotalLabourDays}
labourDaysMap={labourDaysMap}
setLabourDaysMap={setLabourDaysMap}
// Co2
setCo2SavingsMap={setCo2SavingsMap}
co2SavingsMap={co2SavingsMap}
setTotalCo2Savings={setTotalCo2Savings}
// Energy Cost
setEnergyCostSavingsMap={setEnergyCostSavingsMap}
energyCostSavingsMap={energyCostSavingsMap}
setTotalEnergyCostSavings={setTotalEnergyCostSavings}
// kwh Savings
setKwhSavingsMap={setKwhSavingsMap}
kwhSavingsMap={kwhSavingsMap}
setTotalKwhSavings={setTotalKwhSavings}
/>
);
}
)}
</div>
<StickyImpactBar
hasPendingChanges={hasPendingChanges}
pendingCount={pendingCount}
savedSap={savedMetrics.savedSap}
savedEpc={savedMetrics.savedEpc}
projectedSap={projectedMetrics.projectedSap}
projectedEpc={projectedMetrics.projectedEpc}
projectedCo2={projectedMetrics.projectedCo2}
projectedHeatDemand={projectedMetrics.projectedHeatDemand}
projectedCost={projectedMetrics.projectedCost}
projectedContingency={projectedMetrics.projectedContingency}
onSaveAll={handleSaveAll}
onDiscard={handleDiscardAll}
isPending={isPending}
/>
</>
);
}

View file

@ -3,29 +3,360 @@ import {
getPropertyMeta,
getRecommendations,
getPlanMeta,
getPlanFunding,
getInstalledMeasuresByUprn,
getScenario,
} from "../../utils";
import { sapToEpc, formatNumber } from "@/app/utils";
import {
CloudIcon,
BoltIcon,
CurrencyPoundIcon,
BuildingOfficeIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
export default async function Recommendations(props: {
function getEpcHex(letter: string | null | undefined): string {
switch (letter?.toUpperCase()) {
case "A": return "#117d58";
case "B": return "#2da55c";
case "C": return "#8dbd40";
case "D": return "#f7cd14";
case "E": return "#f3a96a";
case "F": return "#ef8026";
case "G": return "#e41e3b";
default: return "#9ca3af";
}
}
// G on left (worst), A on right (best) — correct EPC scale direction
const EPC_LETTERS = ["G", "F", "E", "D", "C", "B", "A"];
// Returns the horizontal centre position (%) of each EPC band in the 7-segment bar
function epcToBandCenter(letter: string | null | undefined): number {
if (!letter) return 0;
const map: Record<string, number> = { G: 0, F: 1, E: 2, D: 3, C: 4, B: 5, A: 6 };
const idx = map[letter.toUpperCase()] ?? 0;
return ((idx + 0.5) / 7) * 100;
}
export default async function PlanDetail(props: {
params: Promise<{ slug: string; propertyId: string; planId: string }>;
}) {
const params = await props.params;
const propertyMeta = await getPropertyMeta(params.propertyId);
const recommendations = await getRecommendations(params.planId);
const planMeta = await getPlanMeta(params.planId);
const funding = await getPlanFunding(params.planId);
const installedMeasures = await getInstalledMeasuresByUprn(propertyMeta.uprn);
const [recommendations, planMeta, installedMeasures, scenarioData] = await Promise.all([
getRecommendations(params.planId),
getPlanMeta(params.planId),
getInstalledMeasuresByUprn(propertyMeta.uprn),
getScenario(params.planId),
]);
const currentEpc = propertyMeta.currentEpcRating;
const targetEpc =
planMeta.postEpcRating ??
(planMeta.postSapPoints ? sapToEpc(planMeta.postSapPoints) : null);
const valuationLabel = (() => {
if (planMeta.valuationIncreaseAverage)
return `+${planMeta.valuationIncreaseAverage.toFixed(1)}%`;
if (planMeta.valuationIncreaseLowerBound && planMeta.valuationIncreaseUpperBound)
return `+${planMeta.valuationIncreaseLowerBound.toFixed(0)}${planMeta.valuationIncreaseUpperBound.toFixed(0)}%`;
if (planMeta.valuationIncrease)
return `+${planMeta.valuationIncrease.toFixed(1)}%`;
return null;
})();
return (
<div className="leading-loose tracking-wider">
<RecommendationContainer
recommendations={recommendations}
propertyMeta={propertyMeta}
planMeta={planMeta}
funding={funding}
installedMeasures={installedMeasures}
/>
<div className="max-w-[1400px] mx-auto py-10 space-y-12">
{/* ── Header ─────────────────────────────────────────────────── */}
<div>
<h1 className="text-4xl md:text-5xl font-black font-manrope tracking-tighter text-brandblue mb-4">
Plan Details: {planMeta.name ?? "Retrofit Plan"}
</h1>
<p className="text-gray-600 max-w-2xl text-lg leading-relaxed">
A comprehensive transformation strategy designed to improve energy
efficiency, reduce running costs, and maximise property value.
</p>
</div>
{/* ── Executive Summary Bento ──────────────────────────────────── */}
<section className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-6">
{/* EPC Rating card — redesigned with SAP IMPROVEMENT label */}
<div className="md:col-span-2 lg:col-span-2 bg-white p-7 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-5">
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
SAP Improvement
</span>
{/* Current → Target letter badges */}
<div className="flex items-end gap-3">
<div className="flex flex-col items-center gap-1.5">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Current</span>
<span
className="text-5xl font-black font-manrope leading-none"
style={{ color: getEpcHex(currentEpc) }}
>
{currentEpc ?? ""}
</span>
</div>
<svg
className="w-5 h-5 text-gray-300 mb-2 shrink-0"
fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
<div className="flex flex-col items-center gap-1.5">
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Target</span>
<span
className="text-7xl font-black font-manrope leading-none drop-shadow-lg"
style={{ color: getEpcHex(targetEpc) }}
>
{targetEpc ?? ""}
</span>
</div>
</div>
{/* EPC gradient bar with official colors + current-position marker */}
<div className="space-y-1.5">
<div className="flex justify-between text-[9px] font-semibold text-gray-400 px-0.5">
<span>G · Least efficient</span>
<span>Most efficient · A</span>
</div>
<div className="relative h-3 w-full rounded-full overflow-hidden">
<div
className="absolute inset-0 rounded-full"
style={{
background:
"linear-gradient(90deg, #e41e3b 0%, #ef8026 17%, #f3a96a 33%, #f7cd14 50%, #8dbd40 67%, #2da55c 83%, #117d58 100%)",
}}
/>
{currentEpc && (
<div
className="absolute top-0 h-full w-1 bg-white/90 shadow-sm z-10"
style={{ left: `${epcToBandCenter(currentEpc)}%` }}
/>
)}
</div>
{/* Letter labels beneath bar */}
<div className="flex">
{EPC_LETTERS.map((l) => (
<div key={l} className="flex-1 flex justify-center">
<span
className="text-[8px] font-bold"
style={{
color:
l === currentEpc?.toUpperCase() || l === targetEpc?.toUpperCase()
? getEpcHex(l)
: "#d1d5db",
}}
>
{l}
</span>
</div>
))}
</div>
</div>
<p className="text-xs font-medium text-gray-500">
SAP:{" "}
<span className="text-gray-800 font-bold">
{propertyMeta.currentSapPoints != null
? Math.round(propertyMeta.currentSapPoints)
: ""}
</span>
{" → "}
<span className="text-gray-800 font-bold">
{planMeta.postSapPoints?.toFixed(1) ?? ""}
</span>
{" points"}
</p>
</div>
{/* 4 stat tiles — cool blue background, icon at top */}
<div className="md:col-span-2 lg:col-span-4 grid grid-cols-2 gap-4">
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<CloudIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{planMeta.co2Savings != null
? `${planMeta.co2Savings.toFixed(1)}t`
: "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
CO Reduction /yr
</p>
</div>
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<BoltIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{planMeta.energyConsumptionSavings != null
? `${formatNumber(planMeta.energyConsumptionSavings)} kWh`
: "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
Energy Savings /yr
</p>
</div>
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<CurrencyPoundIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{planMeta.energyBillSavings != null
? `£${formatNumber(planMeta.energyBillSavings)}`
: "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
Bill Reduction /yr
</p>
</div>
<div className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<BuildingOfficeIcon className="w-8 h-8 text-brandmidblue" />
<p className="text-2xl font-bold font-manrope text-brandblue">
{valuationLabel ?? "—"}
</p>
<p className="text-[10px] text-gray-500 font-bold uppercase tracking-widest leading-tight">
Valuation Boost
</p>
</div>
</div>
</section>
{/* ── Financial + Recommendations ──────────────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Left: Financial Overview — sticky so it stays visible while scrolling recs */}
<div className="lg:col-span-4 space-y-6 lg:sticky lg:top-8">
<h3 className="text-xs font-bold uppercase tracking-widest text-gray-500">
Financial Overview
</h3>
<div className="bg-brandblue text-white p-8 rounded-2xl shadow-xl shadow-brandblue/10">
<div className="mb-8">
<p className="text-white/60 text-sm font-medium mb-1 uppercase tracking-wider">
Total Investment
</p>
<p className="text-5xl font-black font-manrope">
{planMeta.costOfWorks != null
? `£${formatNumber(planMeta.costOfWorks)}`
: "—"}
</p>
</div>
<div className="pt-6 border-t border-white/10 flex justify-between items-center">
<div>
<p className="text-white/60 text-[10px] font-bold uppercase tracking-widest">
Contingency
</p>
<p className="text-xl font-bold font-manrope text-brandbrown">
{planMeta.contingencyCost != null && planMeta.contingencyCost > 0
? `£${formatNumber(planMeta.contingencyCost)}`
: "—"}
</p>
</div>
{/* Contingency tooltip */}
<div className="relative group cursor-help">
<InformationCircleIcon className="w-5 h-5 text-white/40 group-hover:text-white/80 transition-colors" />
<div className="absolute bottom-full right-0 mb-3 w-64 bg-gray-900 text-white text-xs rounded-xl p-3.5 shadow-2xl z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<p className="leading-relaxed text-white/90">
A contingency buffer is added on top of estimated costs to account for unexpected variations in materials or labour. Rates vary per measure type.
</p>
<div className="absolute top-full right-4 border-4 border-transparent border-t-gray-900" />
</div>
</div>
</div>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
Based on current market rates and property size. Final quotes may
vary following a technical survey. Contingency rates vary per
measure type.
</p>
{/* About These Estimates box */}
<div className="bg-brandlightblue rounded-xl border border-blue-100 p-5">
<div className="flex items-start gap-3">
<InformationCircleIcon className="w-4 h-4 text-brandblue mt-0.5 shrink-0" />
<div>
<p className="text-xs font-bold text-brandblue uppercase tracking-widest mb-2">
About These Estimates
</p>
<ul className="space-y-1.5">
<li className="text-xs text-gray-600 leading-relaxed flex items-start gap-1.5">
<span className="text-brandblue mt-0.5 shrink-0">·</span>
Costs modelled using current market rates and SAP 10.2 methodology.
</li>
<li className="text-xs text-gray-600 leading-relaxed flex items-start gap-1.5">
<span className="text-brandblue mt-0.5 shrink-0">·</span>
Annual savings are projected based on typical occupancy patterns.
</li>
<li className="text-xs text-gray-600 leading-relaxed flex items-start gap-1.5">
<span className="text-brandblue mt-0.5 shrink-0">·</span>
Final quotes confirmed after a technical survey of the property.
</li>
</ul>
</div>
</div>
</div>
{/* Scenario Configuration */}
{scenarioData && (
<div className="bg-white rounded-xl border border-gray-100 p-5 space-y-3">
<p className="text-xs font-bold text-brandblue uppercase tracking-widest">
Scenario Configuration
</p>
{scenarioData.name && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Scenario</span>
<span className="font-semibold text-gray-800">{scenarioData.name}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Housing type</span>
<span className="font-semibold text-gray-800">{scenarioData.housingType}</span>
</div>
{scenarioData.budget != null && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Budget</span>
<span className="font-semibold text-gray-800">£{formatNumber(scenarioData.budget)}</span>
</div>
)}
{scenarioData.goal && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Goal</span>
<span className="font-semibold text-gray-800 text-right">
{scenarioData.goal}
{scenarioData.goalValue ? `${scenarioData.goalValue}` : ""}
</span>
</div>
)}
</div>
)}
</div>
{/* Right: Recommended Upgrades — scrollable to avoid excessive page length */}
<div className="lg:col-span-8">
<h3 className="text-xs font-bold uppercase tracking-widest text-gray-500 mb-8 flex items-center gap-3">
Recommended Upgrades
<div className="h-px flex-grow bg-gray-100" />
</h3>
<div className="overflow-y-auto max-h-[780px] pr-2 -mr-2 pb-24">
<RecommendationContainer
recommendations={recommendations}
installedMeasures={installedMeasures}
planId={params.planId}
slug={params.slug}
propertyId={params.propertyId}
currentSapPoints={propertyMeta.currentSapPoints ?? 0}
savedCostOfWorks={planMeta.costOfWorks ?? 0}
savedContingencyCost={planMeta.contingencyCost ?? 0}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -5,6 +5,8 @@ import {
plan,
Plan,
installedMeasure,
scenario,
ScenarioSelect,
} from "@/app/db/schema/recommendations";
import { db } from "@/app/db/db";
import { surveyDB } from "@/app/db/surveyDB/connection";
@ -486,6 +488,18 @@ export function formatHeatDemandFeatures(
];
}
export async function getScenario(planId: string): Promise<ScenarioSelect | null> {
const planData = await db.query.plan.findFirst({
where: eq(plan.id, BigInt(planId)),
columns: { scenarioId: true },
});
if (!planData?.scenarioId) return null;
const data = await db.query.scenario.findFirst({
where: eq(scenario.id, planData.scenarioId),
});
return data ?? null;
}
export async function getInstalledMeasuresByUprn(
uprn: number
): Promise<InstalledMeasureSummary[]> {