diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 66670d0..39004c2 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -2,14 +2,12 @@ import { useState } from "react"; import { - Cog6ToothIcon, NewspaperIcon, HomeModernIcon, WrenchScrewdriverIcon, SunIcon, CircleStackIcon, HeartIcon, - CalendarDaysIcon, } from "@heroicons/react/24/outline"; import { NavigationMenu, @@ -70,17 +68,13 @@ export function Toolbar({ const [openModal, setOpenModal] = useState(false); const [showToast, setShowToast] = useState(false); - function handleClickSettings() { - console.log("Settings were clicked, implement me"); - } - const preAssessmentReportButton = ( - Data + Property Details ); @@ -134,7 +128,7 @@ export function Toolbar({ href={`/portfolio/${portfolioId}/building-passport/${propertyId}`} > - Summary + Overview @@ -145,13 +139,6 @@ export function Toolbar({ {solarAnalysisButton} {recommendationsButton} {documentsButton} - - - Settings - diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 24c014e..6b1dd09 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import { ReactQueryProvider } from "./ReactQueryProvider"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { getServerSession } from "next-auth/next"; import { cache } from "react"; -import { Inter } from "next/font/google"; +import { Inter, Manrope } from "next/font/google"; import { Toaster } from "@/app/shadcn_components/ui/toaster"; import { SpeedInsights } from "@vercel/speed-insights/next"; import type { Metadata } from "next"; @@ -16,6 +16,12 @@ const inter = Inter({ variable: "--font-inter", }); +const manrope = Manrope({ + subsets: ["latin"], + variable: "--font-manrope", + weight: ["400", "500", "700", "800"], +}); + export const metadata = { title: "Ara", description: "Ara is Domna’s portfolio intelligence platform that turns housing stock data into clear, costed retrofit and investment plans.", @@ -61,7 +67,7 @@ export default async function RootLayout({ const userImage = session?.user?.image; return ( - + diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/HeritageTooltip.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/HeritageTooltip.tsx new file mode 100644 index 0000000..588a3b4 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/HeritageTooltip.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; + +export function HeritageTooltip() { + return ( + + + + + + +
+

Planning Restrictions

+

Conservation, listed & heritage properties

+
+
+

+ Properties in a conservation area or with{" "} + listed or{" "} + heritage status may have restrictions on + certain improvement measures, including: +

+
    +
  • Solar panel installation
  • +
  • External wall insulation
  • +
  • Alterations to windows, doors, or roof materials
  • +
+
+
+

+ Always consult your local planning authority to + confirm which measures are permitted before commissioning any works. +

+
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx new file mode 100644 index 0000000..bdc7d17 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/EpcInfoTooltip.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; + +const EPC_BANDS = [ + { band: "A", range: "92–100", color: "#117d58", desc: "Exceptional, near-zero energy bills, usually new-builds or eco-homes." }, + { band: "B", range: "81–91", color: "#2da55c", desc: "Very efficient, often featuring solar panels, high-grade insulation, and modern heating." }, + { band: "C", range: "69–80", color: "#8dbd40", desc: "Good, above-average efficiency; common target for retrofitting existing homes." }, + { band: "D", range: "55–68", color: "#f7cd14", desc: "Average, the typical rating for many homes in the UK." }, + { band: "E", range: "39–54", color: "#f3a96a", desc: "Below average, likely requires better insulation and boiler upgrades." }, + { band: "F", range: "21–38", color: "#ef8026", desc: "Poor, high energy costs and lower energy performance." }, + { band: "G", range: "1–20", color: "#e41e3b", desc: "Very poor, least efficient, high energy costs." }, +]; + +export function EpcInfoTooltip() { + return ( + + + + + + +
+

EPC Rating Bands

+

Based on the SAP score (1–100)

+
+
+ {EPC_BANDS.map(({ band, range, color, desc }) => ( +
+ + {band} + +
+

{range}

+

{desc}

+
+
+ ))} +
+
+

+ SAP score — Standard Assessment Procedure. A government-approved method for rating the energy performance of homes on a scale of 1 to 100. +

+
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx index 084954b..78d7b26 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -1,189 +1,643 @@ -import EpcCard from "@/app/components/building-passport/EpcCard"; -import FeatureTable from "@/app/components/building-passport/FeatureTable"; import { - PropertyDetailsEpc, - PropertyDetailsSpatial, - PropertyMeta, -} from "@/app/db/schema/property"; + BoltIcon, + SunIcon, + Squares2X2Icon, + SparklesIcon, +} from "@heroicons/react/24/outline"; +import { Card, CardContent, CardHeader, CardTitle } from "@/app/shadcn_components/ui/card"; +import { Separator } from "@/app/shadcn_components/ui/separator"; import { formatDateTime } from "@/app/utils"; import { - generalColumns, - nonInstrusiveColumns, - retrofitColumns, -} from "@/app/components/building-passport/FeatureTableColumns"; -import { - formatGeneralFeatures, - formatHeatDemandFeatures, formatRetrofitFeatures, - getConditionReport, - getPropertyMeta, - getSpatialData, + formatGeneralFeatures, getNonIntrusiveSurvey, - getDocument, - getEnergyAssessmentFromS3, + getPropertyMeta, + getConditionReport, + getSpatialData, } from "../utils"; +import { getSolarData, getSolarScenarioData } from "../solar-analysis/utils"; +import PropertyMapWrapper from "../solar-analysis/PropertyMapWrapper"; +import SolarSimulationWrapper from "../solar-analysis/SolarSimulationWrapper"; +import { EpcInfoTooltip } from "./EpcInfoTooltip"; -interface PropertyDetailsCardProps { - conditionReportData: PropertyDetailsEpc; - propertyMeta: PropertyMeta; - propertyDetailsSpatial: PropertyDetailsSpatial; +// ── Helpers ──────────────────────────────────────────────────────────────────── + +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"; + } } -const rowTitleStyle = "text-brandblue align-top pb-3"; -const rowValueStyle = "text-brandblue text-end pr-8 pt-1 align-top pb-3"; +function getEpcDescription(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": + case "B": return "This property is performing at or above modern energy standards."; + case "C": return "This property meets modern energy performance benchmarks."; + case "D": return "This property is performing slightly below modern energy standards."; + case "E": return "This property is performing below modern energy standards."; + case "F": + case "G": return "This property is performing significantly below modern energy standards."; + default: return "Energy performance data is not yet available for this property."; + } +} -function PropertyDetailsCard({ - conditionReportData, - propertyMeta, - propertyDetailsSpatial, -}: PropertyDetailsCardProps) { - const propertyText = [propertyMeta.builtForm, propertyMeta.propertyType] - .filter(Boolean) - .join(" "); +function getDirectionLabel(az: number): { label: string; short: string } { + const norm = ((az % 360) + 360) % 360; + if (norm >= 337.5 || norm < 22.5) return { short: "N", label: "North" }; + if (norm < 67.5) return { short: "NE", label: "North-East" }; + if (norm < 112.5) return { short: "E", label: "East" }; + if (norm < 157.5) return { short: "SE", label: "South-East" }; + if (norm < 202.5) return { short: "S", label: "South" }; + if (norm < 247.5) return { short: "SW", label: "South-West" }; + if (norm < 292.5) return { short: "W", label: "West" }; + return { short: "NW", label: "North-West" }; +} + +function getRatingClasses(rating: string): string { + switch (rating) { + case "Very good": return "bg-green-600 text-white"; + case "Good": return "bg-green-100 text-green-800"; + case "Poor": return "bg-surface-container text-on-surface-variant"; + case "Very poor": return "bg-error-container text-error"; + default: return "bg-gray-100 text-gray-400"; + } +} + +function formatDate(date: Date): string { + return new Date(date).toLocaleDateString("en-GB", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); +} + +const SEGMENT_THEMES: Record = { + S: { gradient: "from-amber-50 to-orange-50/60", border: "border-amber-200/80", badge: "bg-amber-100 text-amber-800 border-amber-300", label: "text-amber-900", dot: "bg-amber-400" }, + SE: { gradient: "from-orange-50 to-amber-50/60", border: "border-orange-200/80", badge: "bg-orange-100 text-orange-800 border-orange-300", label: "text-orange-900", dot: "bg-orange-400" }, + SW: { gradient: "from-orange-50 to-amber-50/60", border: "border-orange-200/80", badge: "bg-orange-100 text-orange-800 border-orange-300", label: "text-orange-900", dot: "bg-orange-400" }, + E: { gradient: "from-sky-50 to-blue-50/40", border: "border-sky-200/80", badge: "bg-sky-100 text-sky-800 border-sky-300", label: "text-sky-900", dot: "bg-sky-400" }, + W: { gradient: "from-sky-50 to-blue-50/40", border: "border-sky-200/80", badge: "bg-sky-100 text-sky-800 border-sky-300", label: "text-sky-900", dot: "bg-sky-400" }, + N: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" }, + NE: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" }, + NW: { gradient: "from-slate-50 to-gray-50/40", border: "border-slate-200/80", badge: "bg-slate-100 text-slate-700 border-slate-300", label: "text-slate-800", dot: "bg-slate-400" }, +}; + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function RoofSegmentCard({ + index, + azimuthDegrees, + pitchDegrees, + areaMeters2, + groundAreaMeters2, + sunshineQuantiles, + planeHeightAtCenterMeters, +}: { + index: number; + azimuthDegrees: number; + pitchDegrees: number; + areaMeters2: number; + groundAreaMeters2: number; + sunshineQuantiles: number[]; + planeHeightAtCenterMeters: number; +}) { + const dir = getDirectionLabel(azimuthDegrees); + const theme = SEGMENT_THEMES[dir.short] ?? SEGMENT_THEMES["N"]; + const medianSunshine = sunshineQuantiles?.[4] ?? null; + const peakSunshine = sunshineQuantiles?.[8] ?? null; return ( -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
Year built:{propertyMeta.yearBuilt}
Property Type:{propertyText}
Total floor area: - {`${conditionReportData.totalFloorArea} m`} - 2 -
In conservation area: - {propertyDetailsSpatial.conservationStatus ? "Yes" : "No"} -
Is listed: - {propertyDetailsSpatial.isListedBuilding ? "Yes" : "No"} -
Is heritage: - {propertyDetailsSpatial.isHeritageBuilding ? "Yes" : "No"} -
+
+
+
+
+

+ Segment {index + 1} +

+

+ {dir.label} +

+
+
+ + {dir.short} +
- - - - - - - - - - - - - - - - - - - -
Local Authority:{propertyMeta.localAuthority}
Constituency:{propertyMeta.constituency}
Tenure{propertyMeta.tenure}
Number of Habitable Rooms: - {propertyMeta.numberOfRooms || "unkown"} -
+ {medianSunshine !== null && ( +
+
+ Median sunshine + {Math.round(medianSunshine)} hrs/yr +
+
+
+
+
+ )}
+ +
+ {[ + { label: "Roof area", value: `${areaMeters2.toFixed(1)} m²` }, + { label: "Ground area", value: `${groundAreaMeters2.toFixed(1)} m²` }, + { label: "Pitch", value: `${pitchDegrees.toFixed(1)}°` }, + { label: "Azimuth", value: `${azimuthDegrees.toFixed(1)}°` }, + { label: "Height", value: `${planeHeightAtCenterMeters.toFixed(1)} m` }, + peakSunshine !== null + ? { label: "Peak (P90)", value: `${Math.round(peakSunshine)} hrs` } + : { label: "", value: "" }, + ].map(({ label, value }, i) => + label ? ( +
+
{label}
+
{value}
+
+ ) : ( +
+ ) + )} +
); } -const formatDate = (dateString: Date) => { - const date = new Date(dateString); - return date.toLocaleDateString("en-GB", { - weekday: "long", // "Monday" through "Sunday" - year: "numeric", // "2024" - month: "long", // "January" through "December" - day: "numeric", // "1", "2", ..., "31" - }); -}; +// ── Page ─────────────────────────────────────────────────────────────────────── export default async function PreAssessmentReport(props: { params: Promise<{ slug: string; propertyId: string }>; }) { const params = await props.params; const propertyMeta = await getPropertyMeta(params.propertyId); - const conditionReportData = await getConditionReport(params.propertyId); - const propertyDetailsSpatial = await getSpatialData(propertyMeta.uprn); - const generalFeatures = formatGeneralFeatures( - conditionReportData, - propertyMeta.propertyType - ); - + const conditionReport = await getConditionReport(params.propertyId); + const spatial = await getSpatialData(Number(propertyMeta.uprn)); const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn); - const retrofitFeatures = formatRetrofitFeatures(conditionReportData); - - const heatingDemand = formatHeatDemandFeatures(conditionReportData); - - // If total floor area is missing, we have a problem - if (conditionReportData.totalFloorArea == null) { + if (conditionReport.totalFloorArea == null) { console.error("Total floor area is missing"); return null; } - return ( -
-
- Last updated: {formatDateTime(propertyMeta.updatedAt)} -
-
-
- + const retrofitFeatures = formatRetrofitFeatures(conditionReport); + const fundamentalDetails = [ + { feature: "Property type", description: propertyMeta.propertyType ?? "Unknown" }, + { feature: "Built form", description: propertyMeta.builtForm ?? "Unknown" }, + { feature: "Floor area", description: conditionReport.totalFloorArea != null ? `${Math.round(conditionReport.totalFloorArea)} m²` : "Unknown" }, + { feature: "Age", description: propertyMeta.yearBuilt != null ? String(propertyMeta.yearBuilt) : "Unknown" }, + ]; + const generalFeatures = [ + ...fundamentalDetails, + ...formatGeneralFeatures(conditionReport, propertyMeta.propertyType) + .filter((f) => !["Mains gas", "Year built", "Property type", "Floor area", "Habitable rooms"].includes(f.feature)) + .slice(0, 4), + ]; - = { + HIGH: "bg-emerald-50 text-emerald-700 border-emerald-200", + MEDIUM: "bg-amber-50 text-amber-700 border-amber-200", + LOW: "bg-gray-50 text-gray-600 border-gray-200", + }; + const qualityBadge = imageryQuality ? (qualityColors[imageryQuality] ?? qualityColors.LOW) : ""; + const qualityText = imageryQuality === "HIGH" ? "High quality" + : imageryQuality === "MEDIUM" ? "Medium quality" + : "Base quality"; + + return ( +
+ + {/* ── Page Header ─────────────────────────────────────────────────────── */} +
+
+

+ Structural Analysis +

+

+ Property Details +

+
+

+ Last updated: {formatDateTime(propertyMeta.updatedAt)} +

+
+ + {/* ── Row 1: EPC hero + energy metrics + general features ──────────── */} +
+ + {/* EPC Hero — matches overview page style */} +
+
+
+
+

+ Current Efficiency State +

+ +
+
+ + {epcLetter ?? "—"} + + + / {sapScore || "—"} + +
+

+ {getEpcDescription(epcLetter)} +

+
+
+
+
+
+
+ Very Inefficient + Very Efficient +
+
+
+ + {/* Right column: 3 metric cards + general features grid */} +
+
+
+

+ Energy Demand +

+
+

+ {conditionReport.currentEnergyDemand != null + ? Number(conditionReport.currentEnergyDemand).toFixed(0) + : "—"} +

+

kWh / year

+
+
+
+

+ CO₂ Emissions +

+
+

+ {conditionReport.co2Emissions ?? "—"} +

+

tonnes / year

+
+
+
+

+ Primary Energy +

+
+

+ {conditionReport.primaryEnergyConsumption ?? "—"} +

+

kWh / m² / year

+
+
+
+ + {/* General features grid — fills remaining height */} + {generalFeatures.length > 0 && ( +
+

+ General Features +

+
+ {generalFeatures.map((f) => { + const desc = String(f.description ?? ""); + const isUnknown = desc === "Unknown" || desc === ""; + return ( +
+

+ {f.feature} +

+

+ {isUnknown ? "Unknown" : desc} +

+
+ ); + })} +
+
+ )}
+ {/* ── Row 2: Fabric table (full width) ─────────────────────────────────── */} +
+

+ Existing Infrastructure Details +

+
+ + + + + + + + + + {retrofitFeatures.map((f) => ( + + + + + + ))} + +
FeatureDescriptionRating
+ {f.feature} + {f.description} + + {f.rating} + +
+
+
+ + {/* ── Row 3: Non-Intrusive Survey ──────────────────────────────────────── */} {nonIntrusiveSurvey && ( -
-
Non-Intrusive Survey
-
- Conducted by: {nonIntrusiveSurvey.surveyor} on{" "} - {formatDate(nonIntrusiveSurvey.surveyDate)} +
+
+

+ Non-Intrusive Survey +

+

+ Conducted by{" "} + {nonIntrusiveSurvey.surveyor} + {" "}on {formatDate(nonIntrusiveSurvey.surveyDate)} +

+
+ + + + + + + + + {nonIntrusiveSurvey.notes.map((note: { title: string; note: string }, i: number) => ( + + + + + ))} + +
FeatureRecorded Observation
{note.title}{note.note}
+
+ )} + + {/* ── Solar Section ────────────────────────────────────────────────────── */} + + {/* Solar section header — always shown if we have coords */} + {(solarData || spatial?.latitude) && ( +
+
+ +
+
+

+ Solar Potential Analysis +

-
)} -
General Features
- -
Existing Property Features
- -
Heating Demand
- + + {solarData && sp ? ( + <> + {/* Row 4: Solar info + Rooftop Summary side by side, then map below */} +
+ + {/* Left: scenario context + imagery info + rooftop summary */} +
+ + {/* Scenario / imagery context card */} +
+

+ {solarScenarioData?.scenrioType === "building" + ? "Figures represent the building as a whole." + : "Figures represent this individual unit."} +

+
+ {imageryQuality && ( + + {qualityText} + + )} + {imageryDateStr && ( + + Imagery: {imageryDateStr} + + )} +
+
+ + {/* Rooftop Summary */} + + + + Rooftop Summary + +

+ Key metrics extracted from aerial imagery analysis. +

+
+ +
+ {[ + { + icon: , + label: "Max annual output", + value: `${maxAnnualKwh.toLocaleString()} kWh`, + }, + { + icon: , + label: "Max panel count", + value: `${sp.maxArrayPanelsCount} panels`, + }, + { + icon: , + label: "Max array area", + value: `${sp.maxArrayAreaMeters2.toFixed(0)} m²`, + }, + { + icon: , + label: "Roof faces identified", + value: `${roofSegmentStats.length}`, + }, + { + icon: , + label: "Max sunshine hours", + value: `${Math.round(sp.maxSunshineHoursPerYear).toLocaleString()} hrs/yr`, + }, + { + icon: , + label: "Carbon offset factor", + value: `${Math.round(sp.carbonOffsetFactorKgPerMwh)} kg/MWh`, + }, + { + icon: , + label: "Panel dimensions", + value: `${sp.panelWidthMeters} m × ${sp.panelHeightMeters} m`, + }, + { + icon: , + label: "Panel capacity", + value: `${sp.panelCapacityWatts} W`, + }, + ].map(({ icon, label, value }, i) => ( +
+
+ + {icon} + + {label} +
+ {value} +
+ ))} +
+
+
+
+ + {/* Right: Map */} +
+ +
+
+ + {/* Row 5: Roof Profile */} + {roofSegmentStats.length > 0 && ( +
+
+

+ Roof Profile +

+

+ {roofSegmentStats.length} roof face{roofSegmentStats.length !== 1 ? "s" : ""} identified. + South-facing segments with low pitch typically yield the highest solar output. +

+
+
+ {roofSegmentStats.map((seg: any, i: number) => ( + + ))} +
+
+ )} + + {/* Row 6: Solar Simulation */} +
+

+ Solar Configurations +

+

+ All array sizes modelled for this property. The efficiency curve reveals + how output scales with system size — useful for understanding which roof + faces are doing the most work. +

+ +
+ + ) : (spatial?.latitude && spatial?.longitude) ? ( + /* No solar data — show map with annotation */ +
+
+ +
+
+

Solar data unavailable

+

+ Solar potential analysis has not been completed for this property. This may be due to insufficient aerial imagery coverage or the property type may not be suitable for solar assessment. +

+
+
+
+
+
+ +
+

Solar Analysis Not Available

+

+ We were unable to retrieve solar potential data for this address. This can happen when aerial imagery quality is insufficient, the property is in a densely shaded area, or a solar survey has not yet been commissioned. +

+
+
+ ) : null} +
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx new file mode 100644 index 0000000..b76294c --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx @@ -0,0 +1,581 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { + CheckCircleIcon, + XCircleIcon, + MinusCircleIcon, + HomeIcon, + WrenchScrewdriverIcon, + SparklesIcon, + FireIcon, + ExclamationTriangleIcon, + ClockIcon, +} from "@heroicons/react/24/outline"; +import { + CheckCircleIcon as CheckCircleSolid, + XCircleIcon as XCircleSolid, +} from "@heroicons/react/24/solid"; +import { AlertTriangle, Hourglass } from "lucide-react"; +import { Badge } from "@/app/shadcn_components/ui/badge"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const DISPLAY_NAMES: Record = { + damp_and_mould_growth: "Damp and Mould Growth", + excess_cold: "Excess Cold", + excess_heat: "Excess Heat", + asbestos_and_mm_fibres: "Asbestos and MM Fibres", + biocides: "Biocides", + carbon_monoxide: "Carbon Monoxide", + lead: "Lead", + radiation: "Radiation", + uncombusted_fuel_gas: "Uncombusted Fuel Gas", + volatile_organic_compounds: "Volatile Organic Compounds", + crowding_and_space: "Crowding and Space", + entry_by_intruders: "Entry by Intruders", + lighting: "Lighting", + noise: "Noise", + domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse", + food_safety: "Food Safety", + personal_hygiene_sanitation_and_drainage: "Personal Hygiene, Sanitation, and Drainage", + water_supply: "Water Supply", + falls_associated_with_baths: "Falls Associated with Baths", + falls_on_level_surfaces: "Falls on Level Surfaces", + falls_on_stairs_and_steps: "Falls on Stairs and Steps", + falls_between_levels: "Falls Between Levels", + electrical_hazards: "Electrical Hazards", + fire: "Fire", + flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials", + collision_and_entrapment: "Collision and Entrapment", + explosions: "Explosions", + ergonomics: "Ergonomics", + structural_collapse_and_falling_elements: "Structural Collapse and Falling Elements", + wall_structure: "Wall Structure", + lintels: "Lintels", + wall_finish: "Wall Finish", + roof_structure: "Roof Structure", + roof_finish: "Roof Finish", + chimneys: "Chimneys", + windows: "Windows", + external_doors: "External Doors", + heating_other: "Other Heating Systems", + electrical_systems: "Electrical Systems", + kitchen: "Kitchen", + bathroom: "Bathroom", + kitchen_less_than_20_years_old: "Kitchen Less Than 20 Years Old", + kitchen_adequate_space_and_layout: "Kitchen Has Adequate Space and Layout", + bathroom_less_than_30_years_old: "Bathroom Less Than 30 Years Old", + bathroom_wc_appropriately_located: "Bathroom/WC Appropriately Located", + adequate_external_noise_insulation: "Adequate External Noise Insulation", + efficient_heating_system_type: "Efficient Heating System Type", + efficient_heating_distribution: "Efficient Heating Distribution", + loft_insulation_sufficient: "Loft Insulation Sufficient", + wall_insulation_sufficient: "Wall Insulation Sufficient", +}; + +const SUB_ITEMS_TEXT: Record = { + "Wall Structure in External Area": "Wall Structure Renewal", + "Lintels in External Area": "Lintel Renewal", + "Wall Finish 1 in External Area": "Wall Finish Renewal", + "Brickwork Pointing in External Area": "Brickwork Pointing Renewal", + "Roof Structure 1 in External Area": "Roof Structure Renewal", + "Fascia / Soffit / Bargeboard in External Area": "Fascia / Soffit / Bargeboard Renewal", + "Gutters in External Area": "Gutter Renewal", + "Downpipes in External Area": "Downpipe Renewal", + "Roof Covering 1 in External Area": "Roof Covering Replacement", + "Chimneys in External Area": "Chimney Renewal", + "Windows in Property": "Window Replacement", + "Windows 1 in External Area": "Window Replacement", + "Type and Location of Front Door in Property": "Front Door Replacement", + "Back and Side Doors 1 in External Area": "Door Replacement", + "Back and Side Doors 2 in External Area": "Door Replacement", + "Type of Water Heating in Property": "Water Heating System Replacement", + "Electrics Required in Property": "Electrical System Renewal", + "Adequacy of Kitchen and Type in Property": "Kitchen Renewal", + "Adequacy of Bathroom Location in Property": "Bathroom Renewal", + kitchen_less_than_20_years_old: "Kitchen Replacement", + kitchen_adequate_space_and_layout: "Kitchen Layout Upgrade", + bathroom_less_than_30_years_old: "Bathroom Replacement", + bathroom_wc_appropriately_located: "Bathroom/WC Layout Upgrade", + adequate_external_noise_insulation: "Noise Insulation Upgrade", + efficient_heating_system_type: "Heating System Upgrade", + efficient_heating_distribution: "Heating Distribution Upgrade", + loft_insulation_sufficient: "Loft Insulation Upgrade", + wall_insulation_sufficient: "Wall Insulation Upgrade", +}; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type CriterionKey = "A" | "B" | "C" | "D" | "replacements"; + +type MetaItem = { + criteria: string; + sub_variable: string; + result: string; + expiry_date?: string | null; + install_date?: string | null; +}; + +type ReplacementEntry = { + label: string; + expiry: Date; + install: Date; + remaining: string; + overdue: boolean; +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function getStatusIcon(status: string, size = "w-4 h-4") { + if (status === "pass") return ; + if (status === "fail") return ; + return ; +} + +function getStatusDot(status: string) { + const colorMap: Record = { + pass: "bg-emerald-500", + fail: "bg-red-500", + no_data: "bg-gray-300", + }; + return ; +} + +function getOverallStyles(status: string): { bg: string; text: string; border: string } { + if (status === "pass") return { bg: "bg-emerald-50", text: "text-emerald-800", border: "border-emerald-200" }; + if (status === "fail") return { bg: "bg-red-50", text: "text-red-800", border: "border-red-200" }; + return { bg: "bg-gray-50", text: "text-gray-700", border: "border-gray-200" }; +} + +function getOverallLabel(status: string): string { + if (status === "pass") return "Decent Homes: Pass"; + if (status === "fail") return "Decent Homes: Fail"; + return "Information Missing"; +} + +function parseReplacements(items: MetaItem[]): ReplacementEntry[] { + const today = new Date(); + return items + .filter((r) => r.expiry_date && r.install_date) + .map((r) => { + const expiry = new Date(r.expiry_date!); + const install = new Date(r.install_date!); + const diffMs = expiry.getTime() - today.getTime(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + const years = Math.floor(Math.abs(diffMonths) / 12); + const months = Math.floor(Math.abs(diffMonths) % 12); + const overdue = diffMs < 0; + const remaining = overdue + ? `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago` + : `${years > 0 ? `${years}y ` : ""}${months}m remaining`; + return { + label: SUB_ITEMS_TEXT[r.sub_variable] ?? r.sub_variable, + expiry, + install, + remaining, + overdue, + }; + }) + .sort((a, b) => a.expiry.getTime() - b.expiry.getTime()); +} + +function groupReplacements(entries: ReplacementEntry[]) { + const groups: Record = { + Overdue: [], + "0–6 months": [], + "6–12 months": [], + ">12 months": [], + }; + for (const e of entries) { + const diffMs = e.expiry.getTime() - Date.now(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + if (e.overdue) groups.Overdue.push(e); + else if (diffMonths <= 6) groups["0–6 months"].push(e); + else if (diffMonths <= 12) groups["6–12 months"].push(e); + else groups[">12 months"].push(e); + } + return groups; +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function CriterionPanel({ + title, + description, + items, +}: { + title: string; + description: string; + items: { sub_variable: string; result: string }[]; +}) { + const sorted = [...items].sort((a, b) => { + const order = { fail: 0, no_data: 1, pass: 2 }; + return (order[a.result as keyof typeof order] ?? 1) - (order[b.result as keyof typeof order] ?? 1); + }); + + const failCount = items.filter((i) => i.result === "fail").length; + const passCount = items.filter((i) => i.result === "pass").length; + + return ( +
+
+

{title}

+

{description}

+
+ + {/* Summary pills */} +
+ + + {passCount} Pass + + + + {failCount} Fail + + + + {items.length - passCount - failCount} Not assessed + +
+ + {/* Items list */} +
+ {sorted.map((item, idx) => ( +
+ + {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} + + {getStatusIcon(item.result)} +
+ ))} +
+
+ ); +} + +function ReplacementsPanel({ items }: { items: MetaItem[] }) { + const entries = parseReplacements(items); + const groups = groupReplacements(entries); + + const urgencyConfig: Record = { + Overdue: { + border: "border-red-300", + bg: "bg-red-50", + badge: "bg-red-100 text-red-800 border-red-200", + icon: , + label: "Overdue", + }, + "0–6 months": { + border: "border-orange-300", + bg: "bg-orange-50", + badge: "bg-orange-100 text-orange-800 border-orange-200", + icon: , + label: "0–6 months", + }, + "6–12 months": { + border: "border-yellow-300", + bg: "bg-yellow-50", + badge: "bg-yellow-100 text-yellow-800 border-yellow-200", + icon: , + label: "6–12 months", + }, + ">12 months": { + border: "border-emerald-300", + bg: "bg-emerald-50", + badge: "bg-emerald-100 text-emerald-800 border-emerald-200", + icon: , + label: ">12 months", + }, + }; + + const order = ["Overdue", "0–6 months", "6–12 months", ">12 months"] as const; + const hasAny = order.some((k) => groups[k].length > 0); + + return ( +
+
+

Component Replacements

+

+ Building components grouped by urgency based on their expected replacement dates. +

+
+ + {!hasAny && ( +
+ +

No replacement data available.

+
+ )} + +
+ {order.map((urgency) => { + const cfg = urgencyConfig[urgency]; + const list = groups[urgency]; + return ( +
+ {/* Column header */} +
+
+ {cfg.icon} + {cfg.label} +
+ + {list.length} + +
+ + {/* Cards */} +
+ {list.length === 0 ? ( +

None

+ ) : ( + list.map((entry, idx) => ( +
+

{entry.label}

+

+ {entry.remaining} +

+
+

+ Installed: {entry.install.toLocaleDateString("en-GB")} +

+

+ Expires: {entry.expiry.toLocaleDateString("en-GB")} +

+
+
+ )) + )} +
+
+ ); + })} +
+
+ ); +} + +// ── Main export ─────────────────────────────────────────────────────────────── + +const CRITERIA: { + key: CriterionKey; + letter: string; + label: string; + description: string; + icon: ReactNode; + statusKey: string; +}[] = [ + { + key: "A", + letter: "A", + label: "Statutory Standard", + description: "Meets current statutory minimum standard for housing", + icon: , + statusKey: "criterion_a", + }, + { + key: "B", + letter: "B", + label: "State of Repair", + description: "The home is in a reasonable state of repair", + icon: , + statusKey: "criterion_b", + }, + { + key: "C", + letter: "C", + label: "Modern Facilities", + description: "Has reasonable modern facilities and services", + icon: , + statusKey: "criterion_c", + }, + { + key: "D", + letter: "D", + label: "Thermal Comfort", + description: "Provides a reasonable degree of thermal comfort", + icon: , + statusKey: "criterion_d", + }, +]; + +export default function DecentHomesSummary({ + decentHomes, + decentHomesMeta, +}: { + decentHomes: { + uprn: number; + creation_date: string; + criterion_a: string; + criterion_b: string; + criterion_c: string; + criterion_d: string; + decent_homes: string; + }; + decentHomesMeta: MetaItem[]; +}) { + const [selected, setSelected] = useState("A"); + + const criteriaGroups: Record = { + A: [], B: [], C: [], D: [], + }; + for (const item of decentHomesMeta) { + if (criteriaGroups[item.criteria]) { + criteriaGroups[item.criteria].push({ + sub_variable: item.sub_variable, + result: item.result, + }); + } + } + + const overdueCount = decentHomesMeta.filter((r) => { + if (!r.expiry_date) return false; + return new Date(r.expiry_date).getTime() < Date.now(); + }).length; + + const overallStatus = decentHomes.decent_homes; + const overall = getOverallStyles(overallStatus); + const overallLabel = getOverallLabel(overallStatus); + + const lastUpdated = new Date(decentHomes.creation_date).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }); + + const criterionStatus: Record = { + A: decentHomes.criterion_a, + B: decentHomes.criterion_b, + C: decentHomes.criterion_c, + D: decentHomes.criterion_d, + }; + + return ( +
+ + {/* ── Hero ─────────────────────────────────────────────────────────── */} +
+
+ {overallStatus === "pass" ? ( + + ) : overallStatus === "fail" ? ( + + ) : ( + + )} +
+

{overallLabel}

+

+ Decent Homes Standard assessment · Last updated {lastUpdated} +

+
+
+ + {/* Criteria summary pills */} +
+ {CRITERIA.map((c) => { + const s = criterionStatus[c.letter]; + return ( +
+ {getStatusDot(s)} + Criterion {c.letter} +
+ ); + })} +
+
+ + {/* ── Body: sidebar + content ──────────────────────────────────────── */} +
+ + {/* Sidebar */} +
+ {CRITERIA.map((c) => { + const s = criterionStatus[c.letter]; + const active = selected === c.key; + return ( + + ); + })} + + {/* Replacements */} + +
+ + {/* Content */} +
+ {selected === "replacements" ? ( + + ) : ( + (() => { + const c = CRITERIA.find((x) => x.key === selected)!; + return ( + + ); + })() + )} +
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx index 2d17f52..1211fe5 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx @@ -1,564 +1,9 @@ -import type { ReactNode } from "react"; - import { getPropertyMeta, getDocument, getEnergyAssessmentFromS3, } from "../utils"; - -import { - Card, - CardHeader, - CardTitle, - CardContent, -} from "@/app/shadcn_components/ui/card"; -import { Badge } from "@/app/shadcn_components/ui/badge"; -import { - Tabs, - TabsList, - TabsTrigger, - TabsContent, -} from "@/app/shadcn_components/ui/tabs"; - -import { - Wrench, - AlertTriangle, - Clock, - Hourglass, - CheckCircle, -} from "lucide-react"; - -const DISPLAY_NAMES: Record = { - // Criterion A - HHSRS hazards - damp_and_mould_growth: "Damp and Mould Growth", - excess_cold: "Excess Cold", - excess_heat: "Excess Heat", - asbestos_and_mm_fibres: "Asbestos and MM Fibres", - biocides: "Biocides", - carbon_monoxide: "Carbon Monoxide", - lead: "Lead", - radiation: "Radiation", - uncombusted_fuel_gas: "Uncombusted Fuel Gas", - volatile_organic_compounds: "Volatile Organic Compounds", - crowding_and_space: "Crowding and Space", - entry_by_intruders: "Entry by Intruders", - lighting: "Lighting", - noise: "Noise", - domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse", - food_safety: "Food Safety", - personal_hygiene_sanitation_and_drainage: - "Personal Hygiene, Sanitation, and Drainage", - water_supply: "Water Supply", - falls_associated_with_baths: "Falls Associated with Baths", - falls_on_level_surfaces: "Falls on Level Surfaces", - falls_on_stairs_and_steps: "Falls on Stairs and Steps", - falls_between_levels: "Falls Between Levels", - electrical_hazards: "Electrical Hazards", - fire: "Fire", - flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials", - collision_and_entrapment: "Collision and Entrapment", - explosions: "Explosions", - ergonomics: "Ergonomics", - structural_collapse_and_falling_elements: - "Structural Collapse and Falling Elements", - - // Criterion B - Key building components - wall_structure: "Wall Structure", - lintels: "Lintels", - wall_finish: "Wall Finish", - roof_structure: "Roof Structure", - roof_finish: "Roof Finish", - chimneys: "Chimneys", - windows: "Windows", - external_doors: "External Doors", - heating_other: "Other Heating Systems", - electrical_systems: "Electrical Systems", - kitchen: "Kitchen", - bathroom: "Bathroom", - - // Criterion C - Modern facilities - kitchen_less_than_20_years_old: "Kitchen Less Than 20 Years Old", - kitchen_adequate_space_and_layout: "Kitchen Has Adequate Space and Layout", - bathroom_less_than_30_years_old: "Bathroom Less Than 30 Years Old", - bathroom_wc_appropriately_located: "Bathroom/WC Appropriately Located", - adequate_external_noise_insulation: "Adequate External Noise Insulation", - - // Criterion D - Thermal comfort - efficient_heating_system_type: "Efficient Heating System Type", - efficient_heating_distribution: "Efficient Heating Distribution", - loft_insulation_sufficient: "Loft Insulation Sufficient", - wall_insulation_sufficient: "Wall Insulation Sufficient", -}; - -const SUB_ITEMS_TEXT: Record = { - // Criterion A - Hazards (keep as-is, not replacements) - damp_and_mould_growth: "Damp and Mould Growth", - excess_cold: "Excess Cold", - excess_heat: "Excess Heat", - asbestos_and_mm_fibres: "Asbestos and MM Fibres", - biocides: "Biocides", - carbon_monoxide: "Carbon Monoxide", - lead: "Lead", - radiation: "Radiation", - uncombusted_fuel_gas: "Uncombusted Fuel Gas", - volatile_organic_compounds: "Volatile Organic Compounds", - crowding_and_space: "Crowding and Space", - entry_by_intruders: "Entry by Intruders", - lighting: "Lighting", - noise: "Noise", - domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse", - food_safety: "Food Safety", - personal_hygiene_sanitation_and_drainage: - "Personal Hygiene, Sanitation, and Drainage", - water_supply: "Water Supply", - falls_associated_with_baths: "Falls Associated with Baths", - falls_on_level_surfaces: "Falls on Level Surfaces", - falls_on_stairs_and_steps: "Falls on Stairs and Steps", - falls_between_levels: "Falls Between Levels", - electrical_hazards: "Electrical Hazards", - fire: "Fire", - flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials", - collision_and_entrapment: "Collision and Entrapment", - explosions: "Explosions", - ergonomics: "Ergonomics", - structural_collapse_and_falling_elements: - "Structural Collapse and Falling Elements", - - // Criterion B - Key components - "Wall Structure in External Area": "Wall Structure Renewal", - "Lintels in External Area": "Lintel Renewal", - "Wall Finish 1 in External Area": "Wall Finish Renewal", - "Brickwork Pointing in External Area": "Brickwork Pointing Renewal", - "Roof Structure 1 in External Area": "Roof Structure Renewal", - "Fascia / Soffit / Bargeboard in External Area": - "Fascia / Soffit / Bargeboard Renewal", - "Gutters in External Area": "Gutter Renewal", - "Downpipes in External Area": "Downpipe Renewal", - "Roof Covering 1 in External Area": "Roof Covering Replacement", - "Chimneys in External Area": "Chimney Renewal", - "Windows in Property": "Window Replacement", - "Windows 1 in External Area": "Window Replacement", - "Type and Location of Front Door in Property": "Front Door Replacement", - "Back and Side Doors 1 in External Area": "Door Replacement", - "Back and Side Doors 2 in External Area": "Door Replacement", - "Type of Water Heating in Property": "Water Heating System Replacement", - "Electrics Required in Property": "Electrical System Renewal", - "Adequacy of Kitchen and Type in Property": "Kitchen Renewal", - "Adequacy of Bathroom Location in Property": "Bathroom Renewal", - - // Criterion C - Modern facilities - kitchen_less_than_20_years_old: "Kitchen Replacement", - kitchen_adequate_space_and_layout: "Kitchen Layout Upgrade", - bathroom_less_than_30_years_old: "Bathroom Replacement", - bathroom_wc_appropriately_located: "Bathroom/WC Layout Upgrade", - adequate_external_noise_insulation: "Noise Insulation Upgrade", - - // Criterion D - Thermal comfort - efficient_heating_system_type: "Heating System Upgrade", - efficient_heating_distribution: "Heating Distribution Upgrade", - loft_insulation_sufficient: "Loft Insulation Upgrade", - wall_insulation_sufficient: "Wall Insulation Upgrade", -}; - -const LABEL_MAP: Record = { - pass: "Pass", - fail: "Fail", - no_data: "Not Assessed", -}; - -const OVERALL_LABEL_MAP: Record = { - pass: "Pass", - fail: "Fail", - no_data: "Information Missing", -}; - -const OVERALL_LABEL_COLORS: Record = { - pass: "bg-green-600 hover:bg-green-700", - fail: "bg-red-700 hover:bg-red-800", - no_data: "bg-gray-500 hover:bg-gray-600", -}; - -// status badge - -function StatusBadge({ status }: { status: string }) { - const colors = - status === "pass" - ? "bg-green-600 hover:bg-green-700" - : status === "fail" - ? "bg-red-700 hover:bg-red-800" - : "bg-gray-500 hover:bg-gray-600"; - - return ( - - {LABEL_MAP[status]} - - ); -} - -// urgency badge -function UrgencyBadge({ label }: { label: string }) { - const colorMap: Record = { - Overdue: "bg-red-700", - "0–6 months": "bg-orange-500", - "6–12 months": "bg-yellow-500", - ">12 months": "bg-green-600", - }; - return ( - - {label} - - ); -} - -function CriterionContent({ - title, - items, -}: { - title: string; - items: { sub_variable: string; result: string }[]; -}) { - const sortedItems = [...items].sort((a, b) => { - const order = { fail: 0, no_data: 1, pass: 2 }; - return ( - order[a.result as keyof typeof order] - - order[b.result as keyof typeof order] - ); - }); - - return ( - - - {title} - - -
    - {sortedItems.map((item, idx) => ( -
  • - - {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} - - -
  • - ))} -
-
-
- - ); -} - -function ReplacementsContent({ - items, -}: { - items: { - sub_variable: string; - expiry_date: string | null; - install_date: string | null; - }[]; -}) { - const today = new Date(); - const groups: Record< - string, - { - sub_variable: string; - expiry: Date; - install: Date; - remaining: string; - overdue: boolean; - }[] - > = { - Overdue: [], - "0–6 months": [], - "6–12 months": [], - ">12 months": [], - }; - - items.forEach((item) => { - if (!item.expiry_date || !item.install_date) return; - const expiry = new Date(item.expiry_date); - const install = new Date(item.install_date); - const diffMs = expiry.getTime() - today.getTime(); - const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); - - const years = Math.floor(Math.abs(diffMonths) / 12); - const months = Math.floor(Math.abs(diffMonths) % 12); - - let remaining = ""; - let overdue = false; - if (diffMs < 0) { - overdue = true; - remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`; - } else { - remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`; - } - - const entry = { - sub_variable: SUB_ITEMS_TEXT[item.sub_variable] ?? item.sub_variable, - expiry, - install, - remaining, - overdue, - }; - - if (diffMs < 0) groups.Overdue.push(entry); - else if (diffMonths <= 6) groups["0–6 months"].push(entry); - else if (diffMonths <= 12) groups["6–12 months"].push(entry); - else groups[">12 months"].push(entry); - }); - - // sort within each group - Object.values(groups).forEach((comps) => - comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()), - ); - - const groupOrder: (keyof typeof groups)[] = [ - "Overdue", - "0–6 months", - "6–12 months", - ">12 months", - ]; - - // urgency → card highlight color + icon - const cardStyles: Record = { - Overdue: { - border: "border-l-4 border-red-600", - icon: , - }, - "0–6 months": { - border: "border-l-4 border-orange-500", - icon: , - }, - "6–12 months": { - border: "border-l-4 border-yellow-500", - icon: , - }, - ">12 months": { - border: "border-l-4 border-green-600", - icon: , - }, - }; - - return ( - - - - Upcoming Replacements - - - - {groupOrder.map((urgency) => - groups[urgency].length > 0 ? ( -
- {/* group header */} -
- - - {groups[urgency].length}{" "} - {groups[urgency].length > 1 ? "items" : "item"} - -
-
- {groups[urgency].map((comp, idx) => ( -
-
- - {cardStyles[urgency].icon} - {comp.sub_variable} - -
-
- {comp.remaining} -
- - {`Installed: ${comp.install.toLocaleDateString("en-GB")}`} - - - {`Expired: ${comp.expiry.toLocaleDateString("en-GB")}`} - -
-
-
- ))} -
-
- ) : null, - )} -
-
- - ); -} - -function StatusCircle({ status }: { status: string }) { - const colorMap: Record = { - pass: "bg-green-600", - fail: "bg-red-700", - no_data: "bg-gray-500", - }; - return
; -} - -function DecentHomesSummary({ - decentHomes, - decentHomesMeta, -}: { - decentHomes: { - uprn: number; - creation_date: string; - criterion_a: string; - criterion_b: string; - criterion_c: string; - criterion_d: string; - decent_homes: string; - }; - decentHomesMeta: { - criteria: string; - sub_variable: string; - result: string; - expiry_date?: string | null; - install_date?: string | null; - }[]; -}) { - // There are three possible overall outcomes: "pass", "fail", "no_data" - // overall is "pass" if all criteria are "pass" - // overall is "fail" if any criteria are "fail" - // overall is "no_data" if all criteria are "no_data" or some are "no_data" and others are "pass" - const overallPass = decentHomes.decent_homes; - const lastUpdated = new Date(decentHomes.creation_date).toLocaleDateString( - "en-GB", - { - day: "numeric", - month: "long", - year: "numeric", - }, - ); - - const criteriaGroups: Record< - string, - { sub_variable: string; result: string }[] - > = { - A: [], - B: [], - C: [], - D: [], - }; - - const replacements: { - sub_variable: string; - expiry_date: string | null; - install_date: string | null; - }[] = []; - - decentHomesMeta.forEach((item) => { - if (criteriaGroups[item.criteria]) { - criteriaGroups[item.criteria].push({ - sub_variable: item.sub_variable, - result: item.result, - }); - } - if (item.expiry_date) { - replacements.push({ - sub_variable: item.sub_variable, - expiry_date: item.expiry_date, - install_date: item.install_date ?? null, - }); - } - }); - - const soonCount = replacements.filter((r) => { - if (!r.expiry_date) return false; - const expiry = new Date(r.expiry_date); - return expiry.getTime() < Date.now(); // strictly overdue - }).length; - - return ( -
- - - - Decent Homes Assessment - - - - - {OVERALL_LABEL_MAP[overallPass]} - -

Last updated: {lastUpdated}

-
-
- - - - -
Criterion A
- -
- -
Criterion B
- -
- -
Criterion C
- -
- -
Criterion D
- -
- - - Replacements - {soonCount > 0 && ( - - {soonCount} - - )} - -
- - - - - - - - - - - - - - - - -
-
- ); -} +import DecentHomesSummary from "./DecentHomesSummary"; export default async function DecentHomesPage(props: { params: Promise<{ slug: string; propertyId: string }>; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx index 047090d..ee422a3 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/page.tsx @@ -1,70 +1,360 @@ -import EpcCard from "@/app/components/building-passport/EpcCard"; -import { formatDateTime } from "@/app/utils"; import { - HomeIcon, + BoltIcon, + CloudIcon, + BanknotesIcon, + WrenchScrewdriverIcon, + SparklesIcon, BuildingOfficeIcon, - CalendarIcon, - HomeModernIcon, - ClockIcon, - UserGroupIcon, -} from "@heroicons/react/24/solid"; -import { getPropertyMeta } from "./utils"; + ShieldCheckIcon, +} from "@heroicons/react/24/outline"; +import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; +import { + getPropertyMeta, + getConditionReport, + getSpatialData, + getInstalledMeasuresByUprn, +} from "./utils"; +import { HeritageTooltip } from "./HeritageTooltip"; export const revalidate = 1; -export default async function BuildingPassportHome( - props: { - params: Promise<{ slug: string; propertyId: string }>; +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function formatGbp(value: number | null | undefined): string { + if (value == null) return "—"; + return `£${Math.round(value).toLocaleString("en-GB")}`; +} + +/** Map EPC letter to its hex color from the project's palette */ +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"; } -) { +} + +function getEpcDescription(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": + case "B": return "This property is performing at or above modern energy standards."; + case "C": return "This property meets modern energy performance benchmarks."; + case "D": return "This property is performing slightly below modern energy standards."; + case "E": return "This property is performing below modern energy standards."; + case "F": + case "G": return "This property is performing significantly below modern energy standards."; + default: return "Energy performance data is not yet available for this property."; + } +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function SectionHeading({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( +
+ {icon} +

{label}

+
+ ); +} + +function YesNoBadge({ value }: { value: boolean }) { + return value ? ( + + + Yes + + ) : ( + + + No + + ); +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default async function BuildingPassportHome(props: { + params: Promise<{ slug: string; propertyId: string }>; +}) { const params = await props.params; - // This is a server component and because we make the exact same request in the layout, - // the response is cached so we just gain access to the data const propertyMeta = await getPropertyMeta(params.propertyId); + const conditionReport = await getConditionReport(params.propertyId); + const spatial = await getSpatialData(propertyMeta.uprn); + const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn)); + + const annualEnergyCost = + (conditionReport.heatingEnergyCostCurrent ?? 0) + + (conditionReport.hotWaterEnergyCostCurrent ?? 0) + + (conditionReport.lightingEnergyCostCurrent ?? 0); + + const epcLetter = propertyMeta.currentEpcRating ?? null; + const sapScore = propertyMeta.currentSapPoints ?? 0; + const epcHex = getEpcHex(epcLetter); return ( -
-
- -
-
Your property
-
- -
Building Passport Created At:
-
{formatDateTime(propertyMeta.createdAt)}
+
+ + {/* ── Row 1: EPC Hero + Energy Stats ──────────────────────────────── */} +
+ + {/* EPC Hero */} +
+
+
+

+ Current Efficiency State +

+
+ + {epcLetter ?? "—"} + + + / {sapScore || "—"} + +
+

+ {getEpcDescription(epcLetter)} +

-
- -
Property Type:
-
{propertyMeta.propertyType}
+
+
+
+
+
+ Very Inefficient + Very Efficient +
-
- -
Built Form:
-
{propertyMeta.builtForm}
+
+ + {/* Energy Stats + Heritage Status */} +
+ + {/* 3 stat cards */} +
+ + {/* Stat: Energy Demand */} +
+ +
+

Energy Demand

+

+ {conditionReport.currentEnergyDemand != null + ? Math.round(conditionReport.currentEnergyDemand).toLocaleString("en-GB") + : "—"} + kWh/yr +

+
+
+ + {/* Stat: CO₂ Emissions */} +
+ +
+

CO₂ Emissions

+

+ {conditionReport.co2Emissions != null + ? conditionReport.co2Emissions.toFixed(1) + : "—"} + t CO₂/yr +

+
+
+ + {/* Stat: Annual Bills */} +
+ +
+

Est. Annual Bills

+

+ {annualEnergyCost > 0 ? `£${Math.round(annualEnergyCost).toLocaleString("en-GB")}` : "—"} +

+
+
-
- -
Year Built:
-
{propertyMeta.yearBuilt}
-
-
- -
Tenure:
-
{propertyMeta.tenure}
-
-
- -
Number of Habitable Rooms:
-
{propertyMeta.numberOfRooms}
+ + {/* Heritage Status — fills remaining height to match EPC card */} +
+
+
+ + + +

Heritage & Planning Status

+ +
+
+
+

Conservation Area

+ +

+ {spatial.conservationStatus + ? "This property falls within a designated conservation area." + : "No conservation area restrictions apply to this property."} +

+
+
+

Listed Building

+ +

+ {spatial.isListedBuilding + ? "This property is a listed building with statutory protections." + : "This property does not have listed building status."} +

+
+
+

Heritage Building

+ +

+ {spatial.isHeritageBuilding + ? "This property is recognised as a heritage asset." + : "No heritage asset designation applies to this property."} +

+
+
+
+ + {/* ── Row 2: Property Details Grid ────────────────────────────────── */} +
+ } + label="Property Details" + /> +
+ + {/* Building */} +
+

Building

+ + + {Math.round(conditionReport.totalFloorArea)} m² + : "—" + } + /> + + + + : "—" + } + /> +
+ + {/* Location & Status */} +
+

Location & Status

+ + + +
+ + {/* Annual Energy Costs */} +
+

Annual Energy Costs

+ + + + + {annualEnergyCost > 0 && ( +
+ Total (excl. appliances) + + £{Math.round(annualEnergyCost).toLocaleString("en-GB")} + +
+ )} +
+
+
+ + {/* ── Row 3: Installed Measures ────────────────────────────────────── */} + {installedMeasures.length > 0 && ( +
+ } + label="Installed Measures" + /> +
+ {installedMeasures.map((measure, i) => ( +
+
+ + + + {measure.measureType} +
+ {measure.installedAt && ( +

+ Installed {new Date(measure.installedAt).toLocaleDateString("en-GB", { month: "short", year: "numeric" })} +

+ )} +
+ {measure.kwhSavings != null && ( +
+

kWh saved

+

+ {Math.round(measure.kwhSavings).toLocaleString()}/yr +

+
+ )} + {measure.billSavings != null && ( +
+

Bill saving

+

+ £{Math.round(measure.billSavings).toLocaleString()}/yr +

+
+ )} +
+
+ ))} +
+
+ )} +
); -} \ No newline at end of file +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx index f6aa2c6..5c0dce2 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx @@ -1,14 +1,17 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { TrashIcon } from "@heroicons/react/24/outline"; -import { useRouter } from "next/navigation"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { + TrashIcon, + ArrowTrendingUpIcon, + CalendarIcon, + BanknotesIcon, + ArrowRightIcon, +} from "@heroicons/react/24/outline"; +import { useRouter, usePathname } from "next/navigation"; -import EpcCard from "@/app/components/building-passport/EpcCard"; -import GoToPlanButton from "@/app/components/building-passport/GoToPlanButton"; - -import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card"; +import { getEpcColorClass, formatNumber } from "@/app/utils"; import { Dialog, @@ -28,7 +31,6 @@ import { } from "@/app/shadcn_components/ui/table"; import { Button } from "@/app/shadcn_components/ui/button"; -import { formatNumber } from "@/app/utils"; /* ---------------------------------------- Types @@ -68,11 +70,29 @@ async function confirmPlanDeletion(planId: string): Promise { } } +/* ---------------------------------------- + EPC Badge +----------------------------------------- */ +function EpcBadge({ rating, label }: { rating: string; label: string }) { + const colorClass = getEpcColorClass(rating); + return ( +
+ {label} + + {rating} + +
+ ); +} + /* ---------------------------------------- Component ----------------------------------------- */ export default function PlanCard({ expectedEpcRating, + currentEpcRating, createdAt, totalEstimatedCost, totalSapPoints, @@ -80,6 +100,7 @@ export default function PlanCard({ planId, }: { expectedEpcRating: string; + currentEpcRating: string; createdAt: Date; totalEstimatedCost: number; totalSapPoints: number; @@ -87,8 +108,8 @@ export default function PlanCard({ planId: string; }) { const [open, setOpen] = useState(false); - const queryClient = useQueryClient(); const router = useRouter(); + const pathname = usePathname(); /* -------- Preview query -------- */ const { @@ -98,7 +119,7 @@ export default function PlanCard({ } = useQuery({ queryKey: ["planDeletionPreview", planId], queryFn: () => fetchPlanDeletionPreview(planId), - enabled: open, // only fetch when modal opens + enabled: open, }); /* -------- Delete mutation -------- */ @@ -110,67 +131,94 @@ export default function PlanCard({ }, }); + const createdDate = new Date(createdAt).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + + const sapImprovement = Math.round((totalSapPoints + Number.EPSILON) * 100) / 100; + return ( <> - - {/* Delete button */} - +
- {/* EPC */} -
- + {/* Header */} +
+
+

Retrofit Plan

+

+ {planName ?? "Unnamed Plan"} +

+
+
- {/* Content */} -
- - {planName && ( -
- {planName} -
- )} -
+ {/* Body */} +
- -
- Total cost: - £{formatNumber(totalEstimatedCost)} + {/* EPC progression */} +
+ +
+
+ +
-
- Total SAP points: - - {Math.round((totalSapPoints + Number.EPSILON) * 100) / 100} + +
+ + {/* Stats row */} +
+
+
+ + Est. Cost +
+ + £{formatNumber(totalEstimatedCost)}
- -
- {/* Right column */} -
-
- +
+
+ + SAP Gain +
+ + +{sapImprovement} pts + +
+ +
+
+ + Created +
+ {createdDate} +
-
- - {/* ---------------------------------------- - Delete preview modal - ----------------------------------------- */} + {/* CTA */} + +
+
+ + {/* Delete preview modal */} @@ -180,9 +228,7 @@ export default function PlanCard({ {isLoading ? (

Loading deletion preview…

) : isError ? ( -

- Failed to load deletion preview -

+

Failed to load deletion preview

) : (
@@ -195,12 +241,8 @@ export default function PlanCard({ {preview.map((row) => ( - - {row.table} - - - {row.count} - + {row.table} + {row.count} ))} @@ -216,7 +258,6 @@ export default function PlanCard({ > Cancel -
+ + + Panels + Capacity + Roof area + Annual output + + Lifetime output + + ({panelLifetimeYears} yr) + + + kWh / kWp + + + + {solarPanelConfigs.map((cfg, i) => { + const capacityKwp = (cfg.panelsCount * panelCapacityWatts) / 1000; + const areaM2 = cfg.panelsCount * panelWidthMeters * panelHeightMeters; + const annualKwh = Math.round(cfg.yearlyEnergyDcKwh); + const lifetimeKwh = Math.round(cfg.yearlyEnergyDcKwh * panelLifetimeYears); + const efficiency = Math.round(annualKwh / capacityKwp); + const isEven = i % 2 === 0; + + return ( + + + {cfg.panelsCount} + + + {capacityKwp.toFixed(1)} + kWp + + + {areaM2.toFixed(1)} + + + + {annualKwh.toLocaleString()} + kWh + + + {lifetimeKwh.toLocaleString()} + kWh + + + {efficiency.toLocaleString()} + + + ); + })} + +
+
+
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulationWrapper.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulationWrapper.tsx new file mode 100644 index 0000000..cf183f8 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/solar-analysis/SolarSimulationWrapper.tsx @@ -0,0 +1,21 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { ComponentProps } from "react"; +import type SolarSimulation from "./SolarSimulation"; + +const SolarConfigDynamic = dynamic(() => import("./SolarSimulation"), { + ssr: false, + loading: () => ( +
+
+
+
+ ), +}); + +export default function SolarSimulationWrapper( + props: ComponentProps +) { + return ; +} diff --git a/tailwind.config.js b/tailwind.config.js index 3145938..b3b5829 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -155,6 +155,7 @@ module.exports = { }, fontFamily: { sans: ["var(--font-sans)", ...fontFamily.sans], + manrope: ["var(--font-manrope)", ...fontFamily.sans], }, keyframes: { "accordion-down": {