From 2341741270131ba9e5a8144a5d7f33a6aa460d7e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Apr 2026 21:54:26 +0000 Subject: [PATCH] revamping the plan page --- next.config.js | 4 + .../building-passport/RecommendationCard.tsx | 556 +++++++++------ .../RecommendationContainer.tsx | 636 +++++++----------- .../[propertyId]/plans/[planId]/page.tsx | 359 +++++++++- .../building-passport/[propertyId]/utils.ts | 14 + 5 files changed, 960 insertions(+), 609 deletions(-) diff --git a/next.config.js b/next.config.js index a1b6a71c..5d5ce4ae 100644 --- a/next.config.js +++ b/next.config.js @@ -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'], diff --git a/src/app/components/building-passport/RecommendationCard.tsx b/src/app/components/building-passport/RecommendationCard.tsx index d03da7fb..0dc61f4b 100644 --- a/src/app/components/building-passport/RecommendationCard.tsx +++ b/src/app/components/building-passport/RecommendationCard.tsx @@ -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 = { 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>; - costMap: RecommendationMetricMap; - setTotalEstimatedCost: Dispatch>; - sapMap: RecommendationMetricMap; - setSapMap: Dispatch>; - setTotalSapPoints: Dispatch>; - currentSapPoints: number; - setExpectedEpcRating: Dispatch>; - setTotalLabourDays: Dispatch>; - labourDaysMap: RecommendationMetricMap; - setLabourDaysMap: Dispatch>; - setCo2SavingsMap: Dispatch>; - co2SavingsMap: RecommendationMetricMap; - setTotalCo2Savings: Dispatch>; - setEnergyCostSavingsMap: Dispatch>; - energyCostSavingsMap: RecommendationMetricMap; - setTotalEnergyCostSavings: Dispatch>; - setKwhSavingsMap: Dispatch>; - kwhSavingsMap: RecommendationMetricMap; - setTotalKwhSavings: Dispatch>; +// 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 ; + case "roof_insulation": + case "loft_insulation": + case "room_roof_insulation": + case "flat_roof_insulation": + case "sloping_ceiling_insulation": + return ; + case "floor_insulation": + case "suspended_floor_insulation": + case "solid_floor_insulation": + case "exposed_floor_insulation": + return ; + case "windows_glazing": + case "mixed_glazing": + return ; + case "solar_pv": + return ; + case "heating": + case "secondary_heating": + case "sealing_open_fireplace": + return ; + case "hot_water_tank_insulation": + return ; + case "mechanical_ventilation": + case "trickle_vents": + return ; + case "heating_control": + case "cylinder_thermostat": + return ; + case "low_energy_lighting": + return ; + default: + return ; + } +} + +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(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 ( -
-

{getTitle()}

-
- {cardComponent ? ( - cardComponent.description - ) : ( -
No measure selected
+
+
+ {/* ── Card header ─────────────────────────────────────────── */} +
+ + {/* Left: circular icon + title block */} +
+ {/* Circular icon */} +
+ {alreadyInstalled ? ( + + ) : ( + + )} +
+ +
+
+

+ {title} +

+ {!isIncluded && !alreadyInstalled && ( + + Not included + + )} +
+ {displayRec && !alreadyInstalled && ( +

+ {displayRec.description} +

+ )} + {!displayRec && !alreadyInstalled && ( +

+ No measure selected +

+ )} + {alreadyInstalled && ( + + Already Installed + + )} +
+
+ + {/* Right: stat chips + status indicators */} +
+ {hasPendingChange && !isPending && ( + + + Pending + + )} + {isPending && ( + + + Saving… + + )} + {displayRec?.sapPoints != null && displayRec.sapPoints > 0 && ( +
+

+ SAP Boost +

+

+ +{displayRec.sapPoints.toFixed(1)} +

+
+ )} + {displayRec?.energyCostSavings != null && + displayRec.energyCostSavings > 0 && ( +
+

+ Savings +

+

+ £{formatNumber(displayRec.energyCostSavings)}/yr +

+
+ )} + {displayRec?.estimatedCost != null && displayRec.estimatedCost > 0 && ( +
+

+ Cost +

+

+ £{formatNumber(displayRec.estimatedCost)} +

+
+ )} +
+
+ + {/* ── Collapsed CTA ───────────────────────────────────────── */} + {isInteractive && !isOpen && ( + + )} + + {/* ── Expanded alternatives panel ─────────────────────────── */} + {isInteractive && isOpen && ( +
+
+ + Detailed Alternatives + +
+
+ + {/* Grid: chosen card + up to 2 alternatives */} +
+ {displaySelected && ( +
+
+

+ {displaySelected.description} +

+ + {hasPendingChange ? "Pending" : "Chosen"} + +
+ {(displaySelected.sapPoints != null && displaySelected.sapPoints > 0) || + (displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0) ? ( +
+ {displaySelected.sapPoints != null && displaySelected.sapPoints > 0 && ( + + +{displaySelected.sapPoints.toFixed(1)} SAP + + )} + {displaySelected.energyCostSavings != null && displaySelected.energyCostSavings > 0 && ( + + £{formatNumber(displaySelected.energyCostSavings)}/yr saved + + )} +
+ ) : null} +

+ £{formatNumber(displaySelected.estimatedCost ?? 0)} +

+
+ )} + + {alternatives.slice(0, displaySelected ? 2 : 3).map((alt) => ( +
+
+

+ {alt.description} +

+ {(alt.sapPoints != null && alt.sapPoints > 0) || + (alt.energyCostSavings != null && alt.energyCostSavings > 0) ? ( +
+ {alt.sapPoints != null && alt.sapPoints > 0 && ( + + +{alt.sapPoints.toFixed(1)} SAP + + )} + {alt.energyCostSavings != null && alt.energyCostSavings > 0 && ( + + £{formatNumber(alt.energyCostSavings)}/yr saved + + )} +
+ ) : null} +
+
+

+ £{formatNumber(alt.estimatedCost ?? 0)} +

+ +
+
+ ))} +
+ + {hasMore && ( + + )} + + {/* Footer actions */} +
+ {(selected || pendingSelection) && ( + + )} + +
+
)}
-
{optionsText}
- {cardComponent ? ( - - - - - - - {cardComponent.sapPoints != null && ( - - - - - )} - -
Estimated Cost: - {cardComponent - ? "£" + formatNumber(cardComponent?.estimatedCost || 0) - : ""} -
SAP Points: - {cardComponent.sapPoints < 0.1 && - cardComponent.type !== "mechanical_ventilation" - ? "Negligible" - : cardComponent.sapPoints} -
- ) : ( - "" - )} - setIsDrawerOpen(false)} + categoryLabel={title} + recs={recs} + selected={selected} + displaySelected={pendingSelection ?? selected} + onSelect={(rec) => { + onSelect(rec); + setIsDrawerOpen(false); + }} + isPending={isPending} />
); diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index e02e2c1d..4e5ada70 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -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 +> = { + 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, ); - 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({ - 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({ - 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({ - 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({ - 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({ - 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({ - 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 + >(() => + 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 + >({}); + + 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 ( +
+
+ +
+

+ No recommendations for this plan. +

+
+ ); + } return ( <> -
- - - - - - - +
+ {sortedCategories.map(([category, recs]) => { + const isIncluded = + !!selections[category] && !recs.some((r) => r.alreadyInstalled); + return ( + handleSelect(category, rec)} + onRemove={() => handleRemove(category)} + /> + ); + })}
- - -
- {Object.entries(categorizedRecommendations).map( - ([componentType, recommendationData], idx) => { - return ( - - ); - } - )} -
+ ); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx index dcce7e4c..b5068bc1 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx @@ -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 = { 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 ( -
- +
+ + {/* ── Header ─────────────────────────────────────────────────── */} +
+

+ Plan Details: {planMeta.name ?? "Retrofit Plan"} +

+

+ A comprehensive transformation strategy designed to improve energy + efficiency, reduce running costs, and maximise property value. +

+
+ + {/* ── Executive Summary Bento ──────────────────────────────────── */} +
+ + {/* EPC Rating card — redesigned with SAP IMPROVEMENT label */} +
+ + SAP Improvement + + + {/* Current → Target letter badges */} +
+
+ Current + + {currentEpc ?? "–"} + +
+ + + +
+ Target + + {targetEpc ?? "–"} + +
+
+ + {/* EPC gradient bar with official colors + current-position marker */} +
+
+ G · Least efficient + Most efficient · A +
+
+
+ {currentEpc && ( +
+ )} +
+ {/* Letter labels beneath bar */} +
+ {EPC_LETTERS.map((l) => ( +
+ + {l} + +
+ ))} +
+
+ +

+ SAP:{" "} + + {propertyMeta.currentSapPoints != null + ? Math.round(propertyMeta.currentSapPoints) + : "–"} + + {" → "} + + {planMeta.postSapPoints?.toFixed(1) ?? "–"} + + {" points"} +

+
+ + {/* 4 stat tiles — cool blue background, icon at top */} +
+ +
+ +

+ {planMeta.co2Savings != null + ? `${planMeta.co2Savings.toFixed(1)}t` + : "—"} +

+

+ CO₂ Reduction /yr +

+
+ +
+ +

+ {planMeta.energyConsumptionSavings != null + ? `${formatNumber(planMeta.energyConsumptionSavings)} kWh` + : "—"} +

+

+ Energy Savings /yr +

+
+ +
+ +

+ {planMeta.energyBillSavings != null + ? `£${formatNumber(planMeta.energyBillSavings)}` + : "—"} +

+

+ Bill Reduction /yr +

+
+ +
+ +

+ {valuationLabel ?? "—"} +

+

+ Valuation Boost +

+
+ +
+
+ + {/* ── Financial + Recommendations ──────────────────────────────── */} +
+ + {/* Left: Financial Overview — sticky so it stays visible while scrolling recs */} +
+

+ Financial Overview +

+ +
+
+

+ Total Investment +

+

+ {planMeta.costOfWorks != null + ? `£${formatNumber(planMeta.costOfWorks)}` + : "—"} +

+
+
+
+

+ Contingency +

+

+ {planMeta.contingencyCost != null && planMeta.contingencyCost > 0 + ? `£${formatNumber(planMeta.contingencyCost)}` + : "—"} +

+
+ {/* Contingency tooltip */} +
+ +
+

+ A contingency buffer is added on top of estimated costs to account for unexpected variations in materials or labour. Rates vary per measure type. +

+
+
+
+
+
+ +

+ Based on current market rates and property size. Final quotes may + vary following a technical survey. Contingency rates vary per + measure type. +

+ + {/* About These Estimates box */} +
+
+ +
+

+ About These Estimates +

+
    +
  • + · + Costs modelled using current market rates and SAP 10.2 methodology. +
  • +
  • + · + Annual savings are projected based on typical occupancy patterns. +
  • +
  • + · + Final quotes confirmed after a technical survey of the property. +
  • +
+
+
+
+ + {/* Scenario Configuration */} + {scenarioData && ( +
+

+ Scenario Configuration +

+ {scenarioData.name && ( +
+ Scenario + {scenarioData.name} +
+ )} +
+ Housing type + {scenarioData.housingType} +
+ {scenarioData.budget != null && ( +
+ Budget + £{formatNumber(scenarioData.budget)} +
+ )} + {scenarioData.goal && ( +
+ Goal + + {scenarioData.goal} + {scenarioData.goalValue ? ` → ${scenarioData.goalValue}` : ""} + +
+ )} +
+ )} +
+ + {/* Right: Recommended Upgrades — scrollable to avoid excessive page length */} +
+

+ Recommended Upgrades +
+

+ +
+ +
+
+
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index fdb7b215..c56b477c 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -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 { + 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 {