mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
revamping the plan page
This commit is contained in:
parent
2a51eae7ec
commit
2341741270
5 changed files with 960 additions and 609 deletions
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue