re-vamping ara ui

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-08 10:29:14 +00:00
parent ff07111edd
commit b9d5166e82
17 changed files with 2313 additions and 842 deletions

View file

@ -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 = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/assessment`}
>
<NewspaperIcon className="h-4 w-4 mr-2" />
Data
Property Details
</NavigationMenuLink>
);
@ -134,7 +128,7 @@ export function Toolbar({
href={`/portfolio/${portfolioId}/building-passport/${propertyId}`}
>
<HomeModernIcon className="h-4 w-4 mr-2" />
Summary
Overview
</NavigationMenuLink>
<NavigationMenuList>
@ -145,13 +139,6 @@ export function Toolbar({
{solarAnalysisButton}
{recommendationsButton}
{documentsButton}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}
>
<Cog6ToothIcon className="h-4 w-4 mr-2" />
Settings
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>

View file

@ -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 Domnas 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 (
<html lang="en" className={inter.className}>
<html lang="en" className={`${inter.className} ${manrope.variable}`}>
<body className="min-h-screen flex flex-col">
<Provider>
<ReactQueryProvider>

View file

@ -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 (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-gray-400 hover:text-brandblue transition-colors focus:outline-none"
aria-label="Heritage and planning restrictions explanation"
>
<QuestionMarkCircleIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-xs p-0 overflow-hidden border-gray-200 shadow-xl"
sideOffset={8}
>
<div className="px-4 pt-3 pb-2 bg-gray-50 border-b border-gray-100">
<p className="text-xs font-bold text-gray-700 uppercase tracking-widest">Planning Restrictions</p>
<p className="text-[11px] text-gray-400 mt-0.5">Conservation, listed &amp; heritage properties</p>
</div>
<div className="px-4 py-3 space-y-2.5">
<p className="text-[11px] text-gray-500 leading-snug">
Properties in a <span className="font-semibold text-gray-700">conservation area</span> or with{" "}
<span className="font-semibold text-gray-700">listed</span> or{" "}
<span className="font-semibold text-gray-700">heritage</span> status may have restrictions on
certain improvement measures, including:
</p>
<ul className="text-[11px] text-gray-500 leading-snug space-y-1 pl-3">
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>Solar panel installation</li>
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>External wall insulation</li>
<li className="flex items-start gap-1.5"><span className="mt-0.5 shrink-0 text-gray-400"></span>Alterations to windows, doors, or roof materials</li>
</ul>
</div>
<div className="px-4 py-2.5 bg-gray-50 border-t border-gray-100">
<p className="text-[11px] text-gray-400 leading-snug">
Always consult your <span className="font-semibold text-gray-600">local planning authority</span> to
confirm which measures are permitted before commissioning any works.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -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: "92100", color: "#117d58", desc: "Exceptional, near-zero energy bills, usually new-builds or eco-homes." },
{ band: "B", range: "8191", color: "#2da55c", desc: "Very efficient, often featuring solar panels, high-grade insulation, and modern heating." },
{ band: "C", range: "6980", color: "#8dbd40", desc: "Good, above-average efficiency; common target for retrofitting existing homes." },
{ band: "D", range: "5568", color: "#f7cd14", desc: "Average, the typical rating for many homes in the UK." },
{ band: "E", range: "3954", color: "#f3a96a", desc: "Below average, likely requires better insulation and boiler upgrades." },
{ band: "F", range: "2138", color: "#ef8026", desc: "Poor, high energy costs and lower energy performance." },
{ band: "G", range: "120", color: "#e41e3b", desc: "Very poor, least efficient, high energy costs." },
];
export function EpcInfoTooltip() {
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="inline-flex items-center justify-center w-4 h-4 rounded-full text-gray-400 hover:text-brandblue transition-colors focus:outline-none"
aria-label="EPC and SAP score explanation"
>
<QuestionMarkCircleIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent
side="right"
className="max-w-xs p-0 overflow-hidden border-gray-200 shadow-xl"
sideOffset={8}
>
<div className="px-4 pt-3 pb-2 bg-gray-50 border-b border-gray-100">
<p className="text-xs font-bold text-gray-700 uppercase tracking-widest">EPC Rating Bands</p>
<p className="text-[11px] text-gray-400 mt-0.5">Based on the SAP score (1100)</p>
</div>
<div className="divide-y divide-gray-50">
{EPC_BANDS.map(({ band, range, color, desc }) => (
<div key={band} className="flex items-start gap-3 px-4 py-2.5">
<span
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center text-white text-xs font-black leading-none mt-0.5"
style={{ backgroundColor: color }}
>
{band}
</span>
<div className="min-w-0">
<p className="text-[11px] font-bold text-gray-700">{range}</p>
<p className="text-[11px] text-gray-400 leading-snug mt-0.5">{desc}</p>
</div>
</div>
))}
</div>
<div className="px-4 py-2.5 bg-gray-50 border-t border-gray-100">
<p className="text-[11px] text-gray-400 leading-snug">
<span className="font-semibold text-gray-600">SAP score</span> Standard Assessment Procedure. A government-approved method for rating the energy performance of homes on a scale of 1 to 100.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -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<string, {
gradient: string;
border: string;
badge: string;
label: string;
dot: string;
}> = {
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 (
<div className="w-full flex flex-col items-center p-4 shadow rounded-md justify-start bg-gray-100">
<div className="grid grid-cols-2 gap-8 text-m w-full h-full text-sm">
<div className="border-r">
<table className="w-full ">
<tbody>
<tr>
<td className={rowTitleStyle}>Year built:</td>
<td className={rowValueStyle}>{propertyMeta.yearBuilt}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Property Type:</td>
<td className={rowValueStyle}>{propertyText}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Total floor area:</td>
<td className={rowValueStyle}>
{`${conditionReportData.totalFloorArea} m`}
<sup>2</sup>
</td>
</tr>
<tr>
<td className={rowTitleStyle}>In conservation area:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.conservationStatus ? "Yes" : "No"}
</td>
</tr>
<tr>
<td className={rowTitleStyle}>Is listed:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.isListedBuilding ? "Yes" : "No"}
</td>
</tr>
<tr>
<td className={rowTitleStyle}>Is heritage:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.isHeritageBuilding ? "Yes" : "No"}
</td>
</tr>
</tbody>
</table>
<div className={`rounded-2xl border bg-gradient-to-br ${theme.gradient} ${theme.border} shadow-sm overflow-hidden`}>
<div className="px-5 pt-5 pb-4">
<div className="flex items-start justify-between mb-3">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1">
Segment {index + 1}
</p>
<h3 className={`text-lg font-bold leading-tight ${theme.label}`}>
{dir.label}
</h3>
</div>
<div className={`flex items-center gap-1 text-xs font-bold px-2.5 py-1 rounded-full border ${theme.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
{dir.short}
</div>
</div>
<table className="w-full">
<tbody>
<tr>
<td className={rowTitleStyle}>Local Authority:</td>
<td className={rowValueStyle}>{propertyMeta.localAuthority}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Constituency:</td>
<td className={rowValueStyle}>{propertyMeta.constituency}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Tenure</td>
<td className={rowValueStyle}>{propertyMeta.tenure}</td>
</tr>
<tr>
<td className={rowTitleStyle}>Number of Habitable Rooms:</td>
<td className={rowValueStyle}>
{propertyMeta.numberOfRooms || "unkown"}
</td>
</tr>
</tbody>
</table>
{medianSunshine !== null && (
<div className="mt-3">
<div className="flex items-center justify-between text-[10px] text-gray-400 mb-1">
<span>Median sunshine</span>
<span className="font-semibold text-gray-600">{Math.round(medianSunshine)} hrs/yr</span>
</div>
<div className="h-1.5 bg-white/60 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${theme.dot} opacity-70`}
style={{ width: `${Math.min(100, (medianSunshine / 1600) * 100)}%` }}
/>
</div>
</div>
)}
</div>
<Separator className="opacity-30" />
<dl className="grid grid-cols-2 gap-px bg-black/5 text-sm">
{[
{ label: "Roof area", value: `${areaMeters2.toFixed(1)}` },
{ label: "Ground area", value: `${groundAreaMeters2.toFixed(1)}` },
{ 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 ? (
<div key={i} className="bg-white/40 px-4 py-3 hover:bg-white/60 transition-colors">
<dt className="text-[10px] uppercase tracking-wide text-gray-400 font-medium mb-0.5">{label}</dt>
<dd className="font-semibold text-gray-700 tabular-nums">{value}</dd>
</div>
) : (
<div key={i} className="bg-white/20" />
)
)}
</dl>
</div>
);
}
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 (
<div className="leading-loose tracking-wider">
<div className="text-gray-700 text-sm mt-4">
Last updated: {formatDateTime(propertyMeta.updatedAt)}
</div>
<div className="flex flex-col items-stretch mb-4">
<div className="flex flex-row justify-start mt-4 space-x-4">
<EpcCard
epcRating={propertyMeta.currentEpcRating}
fullMargin={false}
sap={String(propertyMeta.currentSapPoints)}
/>
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)}` : "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),
];
<PropertyDetailsCard
conditionReportData={conditionReportData}
propertyMeta={propertyMeta}
propertyDetailsSpatial={propertyDetailsSpatial}
const rawSolar = await getSolarData(Number(propertyMeta.uprn));
const solarData = rawSolar ?? null;
const epcLetter = propertyMeta.currentEpcRating ?? null;
const sapScore = propertyMeta.currentSapPoints ?? 0;
const epcHex = getEpcHex(epcLetter);
// Solar derived values
const sp = solarData?.googleApiResponse?.solarPotential ?? null;
const solarScenarioData = solarData ? await getSolarScenarioData(String(solarData.id)) : null;
const maxAnnualKwh = sp
? Math.round(sp.solarPanelConfigs[sp.solarPanelConfigs.length - 1].yearlyEnergyDcKwh)
: 0;
const roofSegmentStats: any[] = sp?.roofSegmentStats ?? [];
const lat = solarData?.googleApiResponse?.center?.latitude ?? spatial?.latitude ?? 0;
const lng = solarData?.googleApiResponse?.center?.longitude ?? spatial?.longitude ?? 0;
const imageryQuality = solarData?.googleApiResponse?.imageryQuality ?? null;
const imageryDate = solarData?.googleApiResponse?.imageryDate ?? null;
const imageryDateStr = imageryDate
? `${imageryDate.year}-${String(imageryDate.month).padStart(2, "0")}-${String(imageryDate.day).padStart(2, "0")}`
: null;
const qualityColors: Record<string, string> = {
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 (
<div className="max-w-7xl mx-auto py-10 space-y-8">
{/* ── Page Header ─────────────────────────────────────────────────────── */}
<header className="flex items-end justify-between">
<div>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Structural Analysis
</p>
<h1 className="font-manrope font-extrabold text-4xl text-brandblue tracking-tighter">
Property Details
</h1>
</div>
<p className="text-sm text-gray-400 font-medium">
Last updated: {formatDateTime(propertyMeta.updatedAt)}
</p>
</header>
{/* ── Row 1: EPC hero + energy metrics + general features ──────────── */}
<div className="grid grid-cols-12 gap-6 items-stretch">
{/* EPC Hero — matches overview page style */}
<section className="col-span-12 lg:col-span-5 bg-white rounded-2xl p-10 flex flex-col justify-between shadow-sm border border-gray-100 relative overflow-hidden">
<div
className="absolute top-0 right-0 w-72 h-72 rounded-full blur-3xl -mr-20 -mt-20 opacity-10"
style={{ backgroundColor: epcHex }}
/>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Current Efficiency State
</p>
<EpcInfoTooltip />
</div>
<div className="flex items-baseline gap-4 mb-4">
<span
className="text-[110px] font-black font-manrope leading-none tracking-tighter"
style={{ color: epcHex }}
>
{epcLetter ?? "—"}
</span>
<span className="text-4xl font-bold font-manrope text-gray-400">
/ {sapScore || ""}
</span>
</div>
<p className="text-gray-500 font-medium text-sm max-w-xs leading-relaxed">
{getEpcDescription(epcLetter)}
</p>
</div>
<div className="mt-10 space-y-3">
<div className="relative h-2.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${Math.min(100, Math.max(2, sapScore))}%`,
background: "linear-gradient(to right, #e41e3b, #ef8026, #f7cd14, #8dbd40, #117d58)",
}}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-gray-400 uppercase tracking-wider">
<span>Very Inefficient</span>
<span>Very Efficient</span>
</div>
</div>
</section>
{/* Right column: 3 metric cards + general features grid */}
<div className="col-span-12 lg:col-span-7 flex flex-col gap-6">
<div className="grid grid-cols-3 gap-6">
<div className="bg-white border border-gray-100 p-7 rounded-2xl flex flex-col justify-between shadow-sm">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">
Energy Demand
</p>
<div>
<p className="text-3xl font-black font-manrope text-brandblue">
{conditionReport.currentEnergyDemand != null
? Number(conditionReport.currentEnergyDemand).toFixed(0)
: "—"}
</p>
<p className="text-sm font-medium text-gray-400 mt-1">kWh / year</p>
</div>
</div>
<div className="bg-white border border-gray-100 p-7 rounded-2xl flex flex-col justify-between shadow-sm">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">
CO Emissions
</p>
<div>
<p className="text-3xl font-black font-manrope text-brandblue">
{conditionReport.co2Emissions ?? "—"}
</p>
<p className="text-sm font-medium text-gray-400 mt-1">tonnes / year</p>
</div>
</div>
<div className="bg-white border border-gray-100 p-7 rounded-2xl flex flex-col justify-between shadow-sm">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">
Primary Energy
</p>
<div>
<p className="text-3xl font-black font-manrope text-brandblue">
{conditionReport.primaryEnergyConsumption ?? "—"}
</p>
<p className="text-sm font-medium text-gray-400 mt-1">kWh / m² / year</p>
</div>
</div>
</div>
{/* General features grid — fills remaining height */}
{generalFeatures.length > 0 && (
<div className="flex-1 bg-white rounded-2xl p-6 shadow-sm border border-gray-100 flex flex-col">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">
General Features
</p>
<div className="grid grid-cols-2 gap-3 flex-1">
{generalFeatures.map((f) => {
const desc = String(f.description ?? "");
const isUnknown = desc === "Unknown" || desc === "";
return (
<div
key={f.feature}
className="bg-gray-50 rounded-xl px-4 py-3 flex flex-col justify-between"
>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
{f.feature}
</p>
<p className={`text-sm font-semibold ${isUnknown ? "text-gray-300 italic" : "text-brandblue"}`}>
{isUnknown ? "Unknown" : desc}
</p>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* ── Row 2: Fabric table (full width) ─────────────────────────────────── */}
<section className="bg-white rounded-2xl p-10 shadow-sm border border-gray-100">
<h2 className="font-manrope font-bold text-xl text-brandblue mb-8">
Existing Infrastructure Details
</h2>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-bold text-gray-400 uppercase tracking-widest border-b border-gray-100">
<th className="pb-4 pr-4 w-40">Feature</th>
<th className="pb-4 px-4">Description</th>
<th className="pb-4 pl-4 text-right w-28">Rating</th>
</tr>
</thead>
<tbody className="text-sm">
{retrofitFeatures.map((f) => (
<tr key={f.feature} className="hover:bg-gray-50 transition-colors">
<td className="py-4 pr-4 font-bold text-brandblue whitespace-nowrap">
{f.feature}
</td>
<td className="py-4 px-4 text-gray-500 font-medium">{f.description}</td>
<td className="py-4 pl-4 text-right whitespace-nowrap">
<span className={`px-3 py-1 rounded-full font-bold text-[10px] uppercase tracking-wider ${getRatingClasses(f.rating)}`}>
{f.rating}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* ── Row 3: Non-Intrusive Survey ──────────────────────────────────────── */}
{nonIntrusiveSurvey && (
<div>
<div className="flex py-8 text-lg">Non-Intrusive Survey</div>
<div className="flex mb-2 text-sm text-gray-500">
Conducted by: {nonIntrusiveSurvey.surveyor} on{" "}
{formatDate(nonIntrusiveSurvey.surveyDate)}
<section className="bg-white rounded-2xl p-10 shadow-sm border border-gray-100">
<div className="flex flex-wrap items-end justify-between gap-4 mb-8">
<h2 className="font-manrope font-bold text-xl text-brandblue">
Non-Intrusive Survey
</h2>
<p className="text-sm text-gray-400">
Conducted by{" "}
<span className="font-semibold text-gray-600">{nonIntrusiveSurvey.surveyor}</span>
{" "}on {formatDate(nonIntrusiveSurvey.surveyDate)}
</p>
</div>
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-bold text-gray-400 uppercase tracking-widest border-b border-gray-100">
<th className="pb-4 pr-4 w-1/4">Feature</th>
<th className="pb-4 pl-4">Recorded Observation</th>
</tr>
</thead>
<tbody className="text-sm">
{nonIntrusiveSurvey.notes.map((note: { title: string; note: string }, i: number) => (
<tr key={i} className="hover:bg-gray-50 transition-colors">
<td className="py-4 pr-4 font-bold text-brandblue">{note.title}</td>
<td className="py-4 pl-4 text-gray-500">{note.note}</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{/* ── Solar Section ────────────────────────────────────────────────────── */}
{/* Solar section header — always shown if we have coords */}
{(solarData || spatial?.latitude) && (
<div className="flex items-center gap-4 pt-2">
<div className="w-8 h-8 rounded-xl bg-amber-50 border border-amber-200/60 flex items-center justify-center shrink-0">
<SunIcon className="w-4 h-4 text-amber-600" />
</div>
<div>
<p className="font-manrope text-xs font-bold text-amber-600 uppercase tracking-widest">
Solar Potential Analysis
</p>
</div>
<FeatureTable
data={nonIntrusiveSurvey.notes}
columns={nonInstrusiveColumns}
/>
</div>
)}
<div className="flex py-8 text-lg">General Features</div>
<FeatureTable data={generalFeatures} columns={generalColumns} />
<div className="flex py-8 text-lg">Existing Property Features</div>
<FeatureTable data={retrofitFeatures} columns={retrofitColumns} />
<div className="flex py-8 text-lg">Heating Demand</div>
<FeatureTable data={heatingDemand} columns={generalColumns} />
{solarData && sp ? (
<>
{/* Row 4: Solar info + Rooftop Summary side by side, then map below */}
<div className="grid grid-cols-12 gap-6">
{/* Left: scenario context + imagery info + rooftop summary */}
<div className="col-span-12 lg:col-span-5 flex flex-col gap-4">
{/* Scenario / imagery context card */}
<div className="bg-amber-50 border border-amber-200/60 rounded-2xl px-5 py-4 flex flex-col gap-3">
<p className="text-sm font-medium text-amber-800 leading-snug">
{solarScenarioData?.scenrioType === "building"
? "Figures represent the building as a whole."
: "Figures represent this individual unit."}
</p>
<div className="flex flex-wrap items-center gap-2">
{imageryQuality && (
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full border ${qualityBadge}`}>
{qualityText}
</span>
)}
{imageryDateStr && (
<span className="text-xs text-amber-700/70 font-medium">
Imagery: {imageryDateStr}
</span>
)}
</div>
</div>
{/* Rooftop Summary */}
<Card className="flex-1 shadow-sm border-gray-200/80">
<CardHeader className="pb-1 pt-6 px-6">
<CardTitle className="text-base font-bold text-brandblue font-manrope">
Rooftop Summary
</CardTitle>
<p className="text-xs text-gray-400 mt-0.5">
Key metrics extracted from aerial imagery analysis.
</p>
</CardHeader>
<CardContent className="px-6 pb-6 pt-2">
<div className="divide-y divide-gray-100 text-sm">
{[
{
icon: <BoltIcon className="w-3.5 h-3.5" />,
label: "Max annual output",
value: `${maxAnnualKwh.toLocaleString()} kWh`,
},
{
icon: <Squares2X2Icon className="w-3.5 h-3.5" />,
label: "Max panel count",
value: `${sp.maxArrayPanelsCount} panels`,
},
{
icon: <Squares2X2Icon className="w-3.5 h-3.5" />,
label: "Max array area",
value: `${sp.maxArrayAreaMeters2.toFixed(0)}`,
},
{
icon: <SparklesIcon className="w-3.5 h-3.5" />,
label: "Roof faces identified",
value: `${roofSegmentStats.length}`,
},
{
icon: <SunIcon className="w-3.5 h-3.5" />,
label: "Max sunshine hours",
value: `${Math.round(sp.maxSunshineHoursPerYear).toLocaleString()} hrs/yr`,
},
{
icon: <SparklesIcon className="w-3.5 h-3.5" />,
label: "Carbon offset factor",
value: `${Math.round(sp.carbonOffsetFactorKgPerMwh)} kg/MWh`,
},
{
icon: <Squares2X2Icon className="w-3.5 h-3.5" />,
label: "Panel dimensions",
value: `${sp.panelWidthMeters} m × ${sp.panelHeightMeters} m`,
},
{
icon: <BoltIcon className="w-3.5 h-3.5" />,
label: "Panel capacity",
value: `${sp.panelCapacityWatts} W`,
},
].map(({ icon, label, value }, i) => (
<div key={i} className="flex items-center justify-between py-3 group">
<div className="flex items-center gap-2.5">
<span className="shrink-0 w-6 h-6 rounded-md bg-brandblue/5 flex items-center justify-center text-brandblue/60 group-hover:bg-brandblue/10 transition-colors">
{icon}
</span>
<span className="text-gray-500">{label}</span>
</div>
<span className="font-semibold text-gray-800 tabular-nums">{value}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Right: Map */}
<div className="col-span-12 lg:col-span-7 rounded-2xl overflow-hidden border border-gray-200 shadow-sm min-h-[500px]">
<PropertyMapWrapper latitude={lat} longitude={lng} />
</div>
</div>
{/* Row 5: Roof Profile */}
{roofSegmentStats.length > 0 && (
<section>
<div className="mb-6">
<h2 className="font-manrope font-bold text-xl text-brandblue mb-1">
Roof Profile
</h2>
<p className="text-sm text-gray-400 leading-relaxed">
{roofSegmentStats.length} roof face{roofSegmentStats.length !== 1 ? "s" : ""} identified.
South-facing segments with low pitch typically yield the highest solar output.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{roofSegmentStats.map((seg: any, i: number) => (
<RoofSegmentCard
key={i}
index={i}
azimuthDegrees={seg.azimuthDegrees}
pitchDegrees={seg.pitchDegrees}
areaMeters2={seg.stats.areaMeters2}
groundAreaMeters2={seg.stats.groundAreaMeters2}
sunshineQuantiles={seg.stats.sunshineQuantiles}
planeHeightAtCenterMeters={seg.planeHeightAtCenterMeters}
/>
))}
</div>
</section>
)}
{/* Row 6: Solar Simulation */}
<section className="bg-white rounded-2xl p-10 shadow-sm border border-gray-100">
<h2 className="font-manrope font-bold text-xl text-brandblue mb-2">
Solar Configurations
</h2>
<p className="text-sm text-gray-400 mb-8 leading-relaxed">
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.
</p>
<SolarSimulationWrapper
solarPanelConfigs={sp.solarPanelConfigs}
panelCapacityWatts={sp.panelCapacityWatts}
panelLifetimeYears={sp.panelLifetimeYears}
panelWidthMeters={sp.panelWidthMeters}
panelHeightMeters={sp.panelHeightMeters}
/>
</section>
</>
) : (spatial?.latitude && spatial?.longitude) ? (
/* No solar data — show map with annotation */
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-7 rounded-2xl overflow-hidden border border-gray-200 shadow-sm relative" style={{ height: "400px" }}>
<PropertyMapWrapper latitude={spatial.latitude} longitude={spatial.longitude} />
<div className="absolute inset-0 flex items-end justify-start p-5 pointer-events-none">
<div className="bg-white/90 backdrop-blur-sm border border-amber-200 rounded-xl px-4 py-3 shadow-sm max-w-xs">
<p className="text-xs font-bold text-amber-700 uppercase tracking-widest mb-1">Solar data unavailable</p>
<p className="text-xs text-gray-500 leading-snug">
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.
</p>
</div>
</div>
</div>
<div className="col-span-12 lg:col-span-5 bg-white rounded-2xl p-8 shadow-sm border border-gray-100 flex flex-col justify-center">
<div className="w-10 h-10 rounded-2xl bg-amber-50 border border-amber-200 flex items-center justify-center mb-4">
<SunIcon className="w-5 h-5 text-amber-500" />
</div>
<h3 className="font-manrope font-bold text-lg text-brandblue mb-2">Solar Analysis Not Available</h3>
<p className="text-sm text-gray-500 leading-relaxed">
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.
</p>
</div>
</div>
) : null}
</div>
);
}

View file

@ -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<string, string> = {
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<string, string> = {
"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 <CheckCircleSolid className={`${size} text-emerald-500 shrink-0`} />;
if (status === "fail") return <XCircleSolid className={`${size} text-red-500 shrink-0`} />;
return <MinusCircleIcon className={`${size} text-gray-400 shrink-0`} />;
}
function getStatusDot(status: string) {
const colorMap: Record<string, string> = {
pass: "bg-emerald-500",
fail: "bg-red-500",
no_data: "bg-gray-300",
};
return <span className={`w-2 h-2 rounded-full shrink-0 ${colorMap[status] ?? "bg-gray-300"}`} />;
}
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<string, ReplacementEntry[]> = {
Overdue: [],
"06 months": [],
"612 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["06 months"].push(e);
else if (diffMonths <= 12) groups["612 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 (
<div className="space-y-5">
<div>
<h3 className="text-base font-bold text-brandblue">{title}</h3>
<p className="text-sm text-gray-400 mt-0.5">{description}</p>
</div>
{/* Summary pills */}
<div className="flex gap-2 flex-wrap">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700">
<CheckCircleSolid className="w-3.5 h-3.5" />
{passCount} Pass
</span>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full bg-red-50 border border-red-200 text-red-700">
<XCircleSolid className="w-3.5 h-3.5" />
{failCount} Fail
</span>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-full bg-gray-50 border border-gray-200 text-gray-600">
<MinusCircleIcon className="w-3.5 h-3.5" />
{items.length - passCount - failCount} Not assessed
</span>
</div>
{/* Items list */}
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden divide-y divide-gray-100">
{sorted.map((item, idx) => (
<div key={idx} className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors">
<span className="text-sm text-gray-700">
{DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
</span>
{getStatusIcon(item.result)}
</div>
))}
</div>
</div>
);
}
function ReplacementsPanel({ items }: { items: MetaItem[] }) {
const entries = parseReplacements(items);
const groups = groupReplacements(entries);
const urgencyConfig: Record<string, {
border: string;
bg: string;
badge: string;
icon: ReactNode;
label: string;
}> = {
Overdue: {
border: "border-red-300",
bg: "bg-red-50",
badge: "bg-red-100 text-red-800 border-red-200",
icon: <AlertTriangle className="w-3.5 h-3.5 text-red-600" />,
label: "Overdue",
},
"06 months": {
border: "border-orange-300",
bg: "bg-orange-50",
badge: "bg-orange-100 text-orange-800 border-orange-200",
icon: <ClockIcon className="w-3.5 h-3.5 text-orange-600" />,
label: "06 months",
},
"612 months": {
border: "border-yellow-300",
bg: "bg-yellow-50",
badge: "bg-yellow-100 text-yellow-800 border-yellow-200",
icon: <Hourglass className="w-3.5 h-3.5 text-yellow-600" />,
label: "612 months",
},
">12 months": {
border: "border-emerald-300",
bg: "bg-emerald-50",
badge: "bg-emerald-100 text-emerald-800 border-emerald-200",
icon: <CheckCircleIcon className="w-3.5 h-3.5 text-emerald-600" />,
label: ">12 months",
},
};
const order = ["Overdue", "06 months", "612 months", ">12 months"] as const;
const hasAny = order.some((k) => groups[k].length > 0);
return (
<div className="space-y-5">
<div>
<h3 className="text-base font-bold text-brandblue">Component Replacements</h3>
<p className="text-sm text-gray-400 mt-0.5">
Building components grouped by urgency based on their expected replacement dates.
</p>
</div>
{!hasAny && (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center">
<WrenchScrewdriverIcon className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No replacement data available.</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{order.map((urgency) => {
const cfg = urgencyConfig[urgency];
const list = groups[urgency];
return (
<div key={urgency} className={`rounded-xl border ${cfg.border} overflow-hidden`}>
{/* Column header */}
<div className={`${cfg.bg} px-4 py-3 flex items-center justify-between border-b ${cfg.border}`}>
<div className="flex items-center gap-1.5">
{cfg.icon}
<span className="text-xs font-bold text-gray-700">{cfg.label}</span>
</div>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full border ${cfg.badge}`}>
{list.length}
</span>
</div>
{/* Cards */}
<div className="bg-white divide-y divide-gray-100">
{list.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-4">None</p>
) : (
list.map((entry, idx) => (
<div key={idx} className="px-4 py-3">
<p className="text-xs font-semibold text-gray-800 mb-1">{entry.label}</p>
<p className={`text-[10px] font-medium ${entry.overdue ? "text-red-600" : "text-gray-500"}`}>
{entry.remaining}
</p>
<div className="flex justify-between mt-1">
<p className="text-[10px] text-gray-400">
Installed: {entry.install.toLocaleDateString("en-GB")}
</p>
<p className={`text-[10px] ${entry.overdue ? "text-red-500" : "text-gray-400"}`}>
Expires: {entry.expiry.toLocaleDateString("en-GB")}
</p>
</div>
</div>
))
)}
</div>
</div>
);
})}
</div>
</div>
);
}
// ── 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: <HomeIcon className="w-4 h-4" />,
statusKey: "criterion_a",
},
{
key: "B",
letter: "B",
label: "State of Repair",
description: "The home is in a reasonable state of repair",
icon: <WrenchScrewdriverIcon className="w-4 h-4" />,
statusKey: "criterion_b",
},
{
key: "C",
letter: "C",
label: "Modern Facilities",
description: "Has reasonable modern facilities and services",
icon: <SparklesIcon className="w-4 h-4" />,
statusKey: "criterion_c",
},
{
key: "D",
letter: "D",
label: "Thermal Comfort",
description: "Provides a reasonable degree of thermal comfort",
icon: <FireIcon className="w-4 h-4" />,
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<CriterionKey>("A");
const criteriaGroups: Record<string, { sub_variable: string; result: string }[]> = {
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<string, string> = {
A: decentHomes.criterion_a,
B: decentHomes.criterion_b,
C: decentHomes.criterion_c,
D: decentHomes.criterion_d,
};
return (
<div className="py-6 space-y-6">
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<div className={`rounded-2xl border ${overall.border} ${overall.bg} px-6 py-5 flex flex-wrap items-center justify-between gap-4`}>
<div className="flex items-center gap-3">
{overallStatus === "pass" ? (
<CheckCircleSolid className="w-8 h-8 text-emerald-500 shrink-0" />
) : overallStatus === "fail" ? (
<XCircleSolid className="w-8 h-8 text-red-500 shrink-0" />
) : (
<ExclamationTriangleIcon className="w-8 h-8 text-gray-400 shrink-0" />
)}
<div>
<p className="text-lg font-bold text-gray-900">{overallLabel}</p>
<p className="text-sm text-gray-500">
Decent Homes Standard assessment · Last updated {lastUpdated}
</p>
</div>
</div>
{/* Criteria summary pills */}
<div className="flex gap-2 flex-wrap">
{CRITERIA.map((c) => {
const s = criterionStatus[c.letter];
return (
<div key={c.key} className="flex items-center gap-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-full px-2.5 py-1">
{getStatusDot(s)}
<span>Criterion {c.letter}</span>
</div>
);
})}
</div>
</div>
{/* ── Body: sidebar + content ──────────────────────────────────────── */}
<div className="flex gap-5">
{/* Sidebar */}
<div className="w-56 shrink-0 space-y-1">
{CRITERIA.map((c) => {
const s = criterionStatus[c.letter];
const active = selected === c.key;
return (
<button
key={c.key}
onClick={() => setSelected(c.key)}
className={`w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-colors
${active
? "bg-brandblue text-white shadow-sm"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<span className={`shrink-0 ${active ? "text-white/80" : "text-brandblue/60"}`}>
{c.icon}
</span>
<div className="flex-1 min-w-0">
<p className={`text-xs font-bold ${active ? "text-white" : "text-brandblue"}`}>
Criterion {c.letter}
</p>
<p className={`text-[11px] truncate ${active ? "text-white/70" : "text-gray-400"}`}>
{c.label}
</p>
</div>
<span className={`shrink-0 w-2 h-2 rounded-full
${s === "pass" ? "bg-emerald-400" : s === "fail" ? "bg-red-400" : "bg-gray-300"}`}
/>
</button>
);
})}
{/* Replacements */}
<button
onClick={() => setSelected("replacements")}
className={`w-full flex items-center gap-3 px-3 py-3 rounded-xl text-left transition-colors relative
${selected === "replacements"
? "bg-brandblue text-white shadow-sm"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<span className={`shrink-0 ${selected === "replacements" ? "text-white/80" : "text-brandblue/60"}`}>
<WrenchScrewdriverIcon className="w-4 h-4" />
</span>
<div className="flex-1 min-w-0">
<p className={`text-xs font-bold ${selected === "replacements" ? "text-white" : "text-brandblue"}`}>
Replacements
</p>
<p className={`text-[11px] ${selected === "replacements" ? "text-white/70" : "text-gray-400"}`}>
Component timeline
</p>
</div>
{overdueCount > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center">
{overdueCount}
</span>
)}
</button>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{selected === "replacements" ? (
<ReplacementsPanel items={decentHomesMeta} />
) : (
(() => {
const c = CRITERIA.find((x) => x.key === selected)!;
return (
<CriterionPanel
title={`Criterion ${c.letter}: ${c.label}`}
description={c.description}
items={criteriaGroups[c.letter]}
/>
);
})()
)}
</div>
</div>
</div>
);
}

View file

@ -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<string, string> = {
// 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<string, string> = {
// 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<string, string> = {
pass: "Pass",
fail: "Fail",
no_data: "Not Assessed",
};
const OVERALL_LABEL_MAP: Record<string, string> = {
pass: "Pass",
fail: "Fail",
no_data: "Information Missing",
};
const OVERALL_LABEL_COLORS: Record<string, string> = {
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 (
<Badge className={`${colors} text-white text-xs px-2 py-1`}>
{LABEL_MAP[status]}
</Badge>
);
}
// urgency badge
function UrgencyBadge({ label }: { label: string }) {
const colorMap: Record<string, string> = {
Overdue: "bg-red-700",
"06 months": "bg-orange-500",
"612 months": "bg-yellow-500",
">12 months": "bg-green-600",
};
return (
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
{label}
</Badge>
);
}
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 (
<Card className="h-96 flex flex-col relative overflow-hidden">
<CardHeader>
<CardTitle className="text-brandbrown">{title}</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-scroll pr-2 scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100">
<ul className="space-y-2 pb-6">
{sortedItems.map((item, idx) => (
<li
key={idx}
className="flex justify-between items-center border-b last:border-0 pb-1"
>
<span className="text-gray-700">
{DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
</span>
<StatusBadge status={item.result} />
</li>
))}
</ul>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
</Card>
);
}
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: [],
"06 months": [],
"612 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["06 months"].push(entry);
else if (diffMonths <= 12) groups["612 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",
"06 months",
"612 months",
">12 months",
];
// urgency → card highlight color + icon
const cardStyles: Record<string, { border: string; icon: ReactNode }> = {
Overdue: {
border: "border-l-4 border-red-600",
icon: <AlertTriangle className="w-4 h-4 text-red-600" />,
},
"06 months": {
border: "border-l-4 border-orange-500",
icon: <Clock className="w-4 h-4 text-orange-500" />,
},
"612 months": {
border: "border-l-4 border-yellow-500",
icon: <Hourglass className="w-4 h-4 text-yellow-500" />,
},
">12 months": {
border: "border-l-4 border-green-600",
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
},
};
return (
<Card className="h-[32rem] flex flex-col relative overflow-hidden">
<CardHeader>
<CardTitle className="text-lg font-medium text-brandbrown">
Upcoming Replacements
</CardTitle>
</CardHeader>
<CardContent className="flex-1 overflow-y-scroll pr-2 scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100">
{groupOrder.map((urgency) =>
groups[urgency].length > 0 ? (
<div key={urgency} className="mb-6">
{/* group header */}
<div className="flex items-center space-x-2 mb-3">
<UrgencyBadge label={urgency} />
<span className="text-gray-700 font-medium">
{groups[urgency].length}{" "}
{groups[urgency].length > 1 ? "items" : "item"}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{groups[urgency].map((comp, idx) => (
<div
key={idx}
className={`px-4 py-2 bg-gray-50 rounded-md border hover:bg-gray-100 transition ${cardStyles[urgency].border}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-800 flex items-center gap-2">
{cardStyles[urgency].icon}
{comp.sub_variable}
</span>
</div>
<div className="text-sm text-gray-600 flex justify-between">
<span>{comp.remaining}</span>
<div className="flex flex-col text-right">
<span className={comp.overdue ? "text-red-600" : ""}>
{`Installed: ${comp.install.toLocaleDateString("en-GB")}`}
</span>
<span className={comp.overdue ? "text-red-600" : ""}>
{`Expired: ${comp.expiry.toLocaleDateString("en-GB")}`}
</span>
</div>
</div>
</div>
))}
</div>
</div>
) : null,
)}
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
</Card>
);
}
function StatusCircle({ status }: { status: string }) {
const colorMap: Record<string, string> = {
pass: "bg-green-600",
fail: "bg-red-700",
no_data: "bg-gray-500",
};
return <div className={`w-4 h-4 rounded-full ${colorMap[status]}`} />;
}
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 (
<div className="flex flex-col items-center mt-10 space-y-6">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle className="text-center text-2xl font-semibold">
Decent Homes Assessment
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-1">
<Badge
className={`px-4 py-2 text-lg ${OVERALL_LABEL_COLORS[overallPass]} text-white`}
>
{OVERALL_LABEL_MAP[overallPass]}
</Badge>
<p className="text-sm text-gray-500">Last updated: {lastUpdated}</p>
</CardContent>
</Card>
<Tabs defaultValue="A" className="w-full max-w-4xl">
<TabsList className="grid grid-cols-5 w-full">
<TabsTrigger value="A">
<div className="mr-4">Criterion A</div>
<StatusCircle status={decentHomes.criterion_a} />
</TabsTrigger>
<TabsTrigger value="B">
<div className="mr-4">Criterion B</div>
<StatusCircle status={decentHomes.criterion_b} />
</TabsTrigger>
<TabsTrigger value="C">
<div className="mr-4">Criterion C</div>
<StatusCircle status={decentHomes.criterion_c} />
</TabsTrigger>
<TabsTrigger value="D">
<div className="mr-4">Criterion D</div>
<StatusCircle status={decentHomes.criterion_d} />
</TabsTrigger>
<TabsTrigger
value="replacements"
className="relative flex items-center space-x-2 text-orange-700 font-medium
data-[state=active]:bg-brandbrown data-[state=active]:rounded-md data-[state=active]:text-gray-100"
>
<Wrench className="w-4 h-4" />
<span>Replacements</span>
{soonCount > 0 && (
<span className="absolute -top-1 -right-2 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{soonCount}
</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="A" className="mt-4">
<CriterionContent
title="Criterion A: The home meets the current statutory minimum standard for housing"
items={criteriaGroups["A"]}
/>
</TabsContent>
<TabsContent value="B" className="mt-4">
<CriterionContent
title="Criterion B: The home is in a reasonable state of repair"
items={criteriaGroups["B"]}
/>
</TabsContent>
<TabsContent value="C" className="mt-4">
<CriterionContent
title="Criterion C: The home has reasonable modern facilities and services"
items={criteriaGroups["C"]}
/>
</TabsContent>
<TabsContent value="D" className="mt-4">
<CriterionContent
title="Criterion D: The home provides a reasonable degree of thermal comfort"
items={criteriaGroups["D"]}
/>
</TabsContent>
<TabsContent value="replacements" className="mt-4">
<ReplacementsContent items={replacements} />
</TabsContent>
</Tabs>
</div>
);
}
import DecentHomesSummary from "./DecentHomesSummary";
export default async function DecentHomesPage(props: {
params: Promise<{ slug: string; propertyId: string }>;

View file

@ -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 (
<div className="flex items-center gap-3 mb-5">
<span className="text-brandblue">{icon}</span>
<h2 className="font-manrope font-bold text-sm text-brandblue uppercase tracking-widest">{label}</h2>
</div>
);
}
function YesNoBadge({ value }: { value: boolean }) {
return value ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full">
<CheckCircleIcon className="w-3 h-3" />
Yes
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-gray-500 bg-gray-50 border border-gray-200 px-2 py-0.5 rounded-full">
<XCircleIcon className="w-3 h-3" />
No
</span>
);
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0 gap-4">
<span className="text-sm text-gray-500 shrink-0">{label}</span>
<span className="text-sm font-semibold text-brandblue text-right">{value}</span>
</div>
);
}
// ── 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 (
<div className="flex flex-col items-center mt-4">
<div className="flex justify-center mt-4 space-x-2">
<EpcCard
epcRating={propertyMeta.currentEpcRating}
fullMargin={false}
kwh={propertyMeta.detailsEpc.currentEnergyDemand}
carbon={propertyMeta.detailsEpc.co2Emissions}
/>
<div className="flex flex-col p-8 bg-white shadow rounded-md max-w-2xl mx-auto justify-start text-gray-700">
<div className="text-2xl font-bold mb-4">Your property</div>
<div className="flex items-center space-x-2 mb-2">
<CalendarIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Building Passport Created At:</div>
<div>{formatDateTime(propertyMeta.createdAt)}</div>
<div className="max-w-7xl mx-auto px-6 py-10 space-y-6">
{/* ── Row 1: EPC Hero + Energy Stats ──────────────────────────────── */}
<div className="grid grid-cols-12 gap-6">
{/* EPC Hero */}
<section className="col-span-12 lg:col-span-5 bg-white rounded-2xl p-10 flex flex-col justify-between shadow-sm border border-gray-100 relative overflow-hidden">
<div
className="absolute top-0 right-0 w-72 h-72 rounded-full blur-3xl -mr-20 -mt-20 opacity-10"
style={{ backgroundColor: epcHex }}
/>
<div className="relative z-10">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-6">
Current Efficiency State
</p>
<div className="flex items-baseline gap-4 mb-4">
<span
className="text-[110px] font-black font-manrope leading-none tracking-tighter"
style={{ color: epcHex }}
>
{epcLetter ?? "—"}
</span>
<span className="text-4xl font-bold font-manrope text-gray-400">
/ {sapScore || ""}
</span>
</div>
<p className="text-gray-500 font-medium text-sm max-w-xs leading-relaxed">
{getEpcDescription(epcLetter)}
</p>
</div>
<div className="flex items-center space-x-2 mb-2">
<HomeIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Property Type:</div>
<div className="text-gray-700">{propertyMeta.propertyType}</div>
<div className="mt-10 space-y-3">
<div className="relative h-2.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${Math.min(100, Math.max(2, sapScore))}%`,
background: "linear-gradient(to right, #e41e3b, #ef8026, #f7cd14, #8dbd40, #117d58)",
}}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-gray-400 uppercase tracking-wider">
<span>Very Inefficient</span>
<span>Very Efficient</span>
</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<BuildingOfficeIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Built Form:</div>
<div className="text-gray-700">{propertyMeta.builtForm}</div>
</section>
{/* Energy Stats + Heritage Status */}
<div className="col-span-12 lg:col-span-7 flex flex-col gap-4">
{/* 3 stat cards */}
<div className="grid grid-cols-3 gap-4">
{/* Stat: Energy Demand */}
<div className="bg-gray-50 rounded-2xl p-7 flex flex-col justify-between">
<BoltIcon className="w-6 h-6 text-brandmidblue mb-4" />
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Energy Demand</p>
<p className="font-manrope text-2xl font-black text-brandblue">
{conditionReport.currentEnergyDemand != null
? Math.round(conditionReport.currentEnergyDemand).toLocaleString("en-GB")
: "—"}
<span className="text-sm font-bold text-gray-400 ml-1.5">kWh/yr</span>
</p>
</div>
</div>
{/* Stat: CO₂ Emissions */}
<div className="bg-gray-50 rounded-2xl p-7 flex flex-col justify-between">
<CloudIcon className="w-6 h-6 text-brandmidblue mb-4" />
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">CO Emissions</p>
<p className="font-manrope text-2xl font-black text-brandblue">
{conditionReport.co2Emissions != null
? conditionReport.co2Emissions.toFixed(1)
: "—"}
<span className="text-sm font-bold text-gray-400 ml-1.5">t CO/yr</span>
</p>
</div>
</div>
{/* Stat: Annual Bills */}
<div className="bg-gray-50 rounded-2xl p-7 flex flex-col justify-between">
<BanknotesIcon className="w-6 h-6 text-brandmidblue mb-4" />
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Est. Annual Bills</p>
<p className="font-manrope text-2xl font-black text-brandblue">
{annualEnergyCost > 0 ? `£${Math.round(annualEnergyCost).toLocaleString("en-GB")}` : "—"}
</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<ClockIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Year Built:</div>
<div className="text-gray-700">{propertyMeta.yearBuilt}</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<UserGroupIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Tenure:</div>
<div className="text-gray-700">{propertyMeta.tenure}</div>
</div>
<div className="flex items-center space-x-2 mb-2">
<HomeModernIcon className="h-5 w-5 text-gray-400" />
<div className="text-gray-500">Number of Habitable Rooms:</div>
<div className="text-gray-700">{propertyMeta.numberOfRooms}</div>
{/* Heritage Status — fills remaining height to match EPC card */}
<div className="flex-1 bg-white rounded-2xl p-8 shadow-sm border border-gray-100 flex flex-col justify-between">
<div>
<div className="flex items-center gap-3 mb-6">
<span className="w-8 h-8 rounded-xl bg-brandblue/8 flex items-center justify-center shrink-0">
<ShieldCheckIcon className="w-4 h-4 text-brandblue" />
</span>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">Heritage & Planning Status</p>
<HeritageTooltip />
</div>
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Conservation Area</p>
<YesNoBadge value={!!spatial.conservationStatus} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.conservationStatus
? "This property falls within a designated conservation area."
: "No conservation area restrictions apply to this property."}
</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Listed Building</p>
<YesNoBadge value={!!spatial.isListedBuilding} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.isListedBuilding
? "This property is a listed building with statutory protections."
: "This property does not have listed building status."}
</p>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Heritage Building</p>
<YesNoBadge value={!!spatial.isHeritageBuilding} />
<p className="text-xs text-gray-400 leading-relaxed">
{spatial.isHeritageBuilding
? "This property is recognised as a heritage asset."
: "No heritage asset designation applies to this property."}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* ── Row 2: Property Details Grid ────────────────────────────────── */}
<div>
<SectionHeading
icon={<BuildingOfficeIcon className="w-4 h-4" />}
label="Property Details"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Building */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Building</p>
<DetailRow label="Year built" value={propertyMeta.yearBuilt ?? "—"} />
<DetailRow
label="Type"
value={[propertyMeta.builtForm, propertyMeta.propertyType].filter(Boolean).join(", ") || "—"}
/>
<DetailRow
label="Floor area"
value={
conditionReport.totalFloorArea != null
? <>{Math.round(conditionReport.totalFloorArea)} m²</>
: "—"
}
/>
<DetailRow label="Storeys" value={conditionReport.numberStoreys ?? "—"} />
<DetailRow label="Habitable rooms" value={propertyMeta.numberOfRooms ?? "—"} />
<DetailRow
label="Mains gas"
value={
conditionReport.mainsGas != null
? <YesNoBadge value={conditionReport.mainsGas} />
: "—"
}
/>
</div>
{/* Location & Status */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Location & Status</p>
<DetailRow label="Local authority" value={propertyMeta.localAuthority ?? "—"} />
<DetailRow label="Constituency" value={propertyMeta.constituency ?? "—"} />
<DetailRow label="Tenure" value={propertyMeta.tenure ?? "—"} />
</div>
{/* Annual Energy Costs */}
<div className="bg-white rounded-2xl p-7 shadow-sm border border-gray-100">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-4">Annual Energy Costs</p>
<DetailRow label="Heating" value={formatGbp(conditionReport.heatingEnergyCostCurrent)} />
<DetailRow label="Hot water" value={formatGbp(conditionReport.hotWaterEnergyCostCurrent)} />
<DetailRow label="Lighting" value={formatGbp(conditionReport.lightingEnergyCostCurrent)} />
<DetailRow label="Appliances" value={formatGbp(conditionReport.appliancesEnergyCostCurrent)} />
{annualEnergyCost > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 flex justify-between">
<span className="text-sm font-bold text-gray-600">Total (excl. appliances)</span>
<span className="text-sm font-bold text-brandblue tabular-nums">
£{Math.round(annualEnergyCost).toLocaleString("en-GB")}
</span>
</div>
)}
</div>
</div>
</div>
{/* ── Row 3: Installed Measures ────────────────────────────────────── */}
{installedMeasures.length > 0 && (
<div>
<SectionHeading
icon={<WrenchScrewdriverIcon className="w-4 h-4" />}
label="Installed Measures"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{installedMeasures.map((measure, i) => (
<div
key={i}
className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm flex flex-col gap-3"
>
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-xl bg-brandblue/8 flex items-center justify-center shrink-0">
<SparklesIcon className="w-4 h-4 text-brandblue" />
</span>
<span className="text-sm font-bold text-brandblue">{measure.measureType}</span>
</div>
{measure.installedAt && (
<p className="text-xs text-gray-400">
Installed {new Date(measure.installedAt).toLocaleDateString("en-GB", { month: "short", year: "numeric" })}
</p>
)}
<div className="grid grid-cols-2 gap-2 mt-1">
{measure.kwhSavings != null && (
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">kWh saved</p>
<p className="text-sm font-semibold text-brandblue tabular-nums">
{Math.round(measure.kwhSavings).toLocaleString()}/yr
</p>
</div>
)}
{measure.billSavings != null && (
<div>
<p className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Bill saving</p>
<p className="text-sm font-semibold text-brandblue tabular-nums">
£{Math.round(measure.billSavings).toLocaleString()}/yr
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
}

View file

@ -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<void> {
}
}
/* ----------------------------------------
EPC Badge
----------------------------------------- */
function EpcBadge({ rating, label }: { rating: string; label: string }) {
const colorClass = getEpcColorClass(rating);
return (
<div className="flex flex-col items-center gap-1">
<span className="text-[10px] uppercase tracking-widest text-gray-400 font-medium">{label}</span>
<span
className={`${colorClass} text-white text-lg font-black w-9 h-9 rounded-lg flex items-center justify-center leading-none shadow-sm`}
>
{rating}
</span>
</div>
);
}
/* ----------------------------------------
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 (
<>
<Card className="relative flex items-start">
{/* Delete button */}
<button
type="button"
onClick={() => setOpen(true)}
className="
absolute top-3 right-3
rounded-md p-1.5
text-gray-400
hover:text-red-600 hover:bg-red-50
focus:outline-none focus:ring-2 focus:ring-red-400/40
transition
"
aria-label="Delete plan"
title="Delete plan"
>
<TrashIcon className="h-4 w-4" />
</button>
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm overflow-hidden hover:shadow-md transition-shadow">
{/* EPC */}
<div className="flex-none w-1/5">
<EpcCard epcRating={expectedEpcRating} fullMargin expected />
{/* Header */}
<div className="flex items-start justify-between px-6 pt-5 pb-4 border-b border-gray-100">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-400 mb-1">Retrofit Plan</p>
<h3 className="text-base font-bold text-brandblue">
{planName ?? "Unnamed Plan"}
</h3>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-lg p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-400/40 transition"
aria-label="Delete plan"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-grow pl-4 flex flex-col justify-between">
<CardHeader className="flex justify-end items-start">
{planName && (
<div className="text-lg font-bold mb-2 text-gray-900">
{planName}
</div>
)}
</CardHeader>
{/* Body */}
<div className="px-6 py-4 flex flex-col gap-5">
<CardContent>
<div className="flex justify-between mb-2">
<span>Total cost:</span>
<span>£{formatNumber(totalEstimatedCost)}</span>
{/* EPC progression */}
<div className="flex items-center gap-4">
<EpcBadge rating={currentEpcRating} label="Current" />
<div className="flex-1 flex items-center gap-1">
<div className="flex-1 h-px bg-gray-200" />
<ArrowRightIcon className="w-4 h-4 text-brandblue shrink-0" />
<div className="flex-1 h-px bg-gray-200" />
</div>
<div className="flex justify-between">
<span>Total SAP points:</span>
<span>
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
<EpcBadge rating={expectedEpcRating} label="Expected" />
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-3 py-2.5">
<div className="flex items-center gap-1.5">
<BanknotesIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Est. Cost</span>
</div>
<span className="text-sm font-bold text-gray-800 tabular-nums">
£{formatNumber(totalEstimatedCost)}
</span>
</div>
</CardContent>
</div>
{/* Right column */}
<div className="flex flex-col justify-end mr-2 self-stretch w-1/5">
<div className="flex flex-col items-end gap-2">
<GoToPlanButton planId={planId} />
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-3 py-2.5">
<div className="flex items-center gap-1.5">
<ArrowTrendingUpIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">SAP Gain</span>
</div>
<span className="text-sm font-bold text-gray-800 tabular-nums">
+{sapImprovement} pts
</span>
</div>
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-3 py-2.5">
<div className="flex items-center gap-1.5">
<CalendarIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Created</span>
</div>
<span className="text-xs font-semibold text-gray-700">{createdDate}</span>
</div>
</div>
</div>
</Card>
{/* ----------------------------------------
Delete preview modal
----------------------------------------- */}
{/* CTA */}
<button
type="button"
onClick={() => router.push(`${pathname}/${planId}`)}
className="flex items-center justify-center gap-2 w-full rounded-xl bg-brandblue text-white text-sm font-semibold py-2.5 hover:bg-hoverblue transition-colors"
>
View Plan
<ArrowRightIcon className="w-4 h-4" />
</button>
</div>
</div>
{/* Delete preview modal */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<ModalHeader>
@ -180,9 +228,7 @@ export default function PlanCard({
{isLoading ? (
<p className="text-sm text-gray-500">Loading deletion preview</p>
) : isError ? (
<p className="text-sm text-red-600">
Failed to load deletion preview
</p>
<p className="text-sm text-red-600">Failed to load deletion preview</p>
) : (
<div className="rounded-md border border-gray-200">
<Table>
@ -195,12 +241,8 @@ export default function PlanCard({
<TableBody>
{preview.map((row) => (
<TableRow key={row.table}>
<TableCell className="font-mono text-sm">
{row.table}
</TableCell>
<TableCell className="text-right font-semibold">
{row.count}
</TableCell>
<TableCell className="font-mono text-sm">{row.table}</TableCell>
<TableCell className="text-right font-semibold">{row.count}</TableCell>
</TableRow>
))}
</TableBody>
@ -216,7 +258,6 @@ export default function PlanCard({
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate()}

View file

@ -13,7 +13,7 @@ export default async function RecommendationPlans(props: {
<div className="leading-loose tracking-wider">
<div className="flex py-8 text-lg">Retrofit Plans</div>
<div className="max-w-3xl">
<div>
{plans.map((plan) => {
const totalEstimatedCost = plan.costOfWorks || 0;
@ -32,6 +32,7 @@ export default async function RecommendationPlans(props: {
<div key={plan.id} className="mb-4">
<PlanCard
expectedEpcRating={expectedEpcRating}
currentEpcRating={propertyMeta.currentEpcRating}
createdAt={plan.createdAt}
totalEstimatedCost={totalEstimatedCost}
totalSapPoints={totalSapPoints}

View file

@ -0,0 +1,67 @@
"use client";
import { useEffect, useRef } from "react";
interface PropertyMapProps {
latitude: number;
longitude: number;
}
function loadMapsScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof google !== "undefined" && google.maps) {
resolve();
return;
}
const existing = document.getElementById("google-maps-script");
if (existing) {
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", reject);
return;
}
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? "";
const script = document.createElement("script");
script.id = "google-maps-script";
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;
script.async = true;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
export default function PropertyMap({ latitude, longitude }: PropertyMapProps) {
const mapDivRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadMapsScript().then(() => {
if (!mapDivRef.current) return;
const position = { lat: latitude, lng: longitude };
const map = new google.maps.Map(mapDivRef.current, {
center: position,
zoom: 18,
mapTypeId: "hybrid",
tilt: 0,
mapTypeControl: false,
streetViewControl: false,
rotateControl: false,
fullscreenControl: false,
zoomControl: true,
scrollwheel: false,
});
new google.maps.Marker({
position,
map,
title: "Property location",
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={mapDivRef}
style={{ height: "100%", width: "100%", minHeight: "320px" }}
/>
);
}

View file

@ -0,0 +1,18 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import type PropertyMap from "./PropertyMap";
const PropertyMapDynamic = dynamic(() => import("./PropertyMap"), {
ssr: false,
loading: () => (
<div className="w-full h-full min-h-[320px] bg-gray-100 animate-pulse rounded-lg" />
),
});
export default function PropertyMapWrapper(
props: ComponentProps<typeof PropertyMap>
) {
return <PropertyMapDynamic {...props} />;
}

View file

@ -0,0 +1,8 @@
"use client";
import type { ComponentProps } from "react";
import SolarPanelMap from "./SolarPanelMap";
export default function SolarMapWrapper(props: ComponentProps<typeof SolarPanelMap>) {
return <SolarPanelMap {...props} />;
}

View file

@ -0,0 +1,216 @@
"use client";
import { useMemo } from "react";
interface SolarPanel {
center: { latitude: number; longitude: number };
orientation: "LANDSCAPE" | "PORTRAIT";
segmentIndex: number;
yearlyEnergyDcKwh: number;
}
interface RoofSegmentStat {
segmentIndex: number;
azimuthDegrees: number;
center: { latitude: number; longitude: number };
stats: { groundAreaMeters2: number };
}
interface SolarPanelMapProps {
activePanels: SolarPanel[];
roofSegmentStats: RoofSegmentStat[];
panelWidthMeters: number;
panelHeightMeters: number;
buildingCenter: { latitude: number; longitude: number };
}
const SEGMENT_STYLES = [
{ fill: "#fef9c3", stroke: "#ca8a04", text: "#92400e" },
{ fill: "#dbeafe", stroke: "#2563eb", text: "#1e40af" },
{ fill: "#dcfce7", stroke: "#16a34a", text: "#166534" },
{ fill: "#fce7f3", stroke: "#db2777", text: "#9d174d" },
];
const PANEL_COLORS = ["#ca8a04", "#2563eb", "#16a34a", "#db2777"];
const SVG_W = 560;
const SVG_H = 440;
const PAD = 56;
function getCardinal(az: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round((((az % 360) + 360) % 360) / 45) % 8];
}
function CompassRose({ cx, cy, r }: { cx: number; cy: number; r: number }) {
return (
<g>
<circle cx={cx} cy={cy} r={r + 5} fill="white" stroke="#e5e7eb" strokeWidth={1} />
<polygon
points={`${cx},${cy - r} ${cx - r * 0.3},${cy + r * 0.1} ${cx + r * 0.3},${cy + r * 0.1}`}
fill="#1e3a5f"
/>
<polygon
points={`${cx},${cy + r} ${cx - r * 0.3},${cy - r * 0.1} ${cx + r * 0.3},${cy - r * 0.1}`}
fill="#d1d5db"
/>
<line x1={cx - r} y1={cy} x2={cx + r} y2={cy} stroke="#9ca3af" strokeWidth={1} />
<text x={cx} y={cy - r - 6} textAnchor="middle" fontSize={11} fontWeight="700" fill="#1e3a5f">N</text>
<text x={cx} y={cy + r + 14} textAnchor="middle" fontSize={10} fill="#9ca3af">S</text>
<text x={cx + r + 10} y={cy + 4} textAnchor="middle" fontSize={10} fill="#9ca3af">E</text>
<text x={cx - r - 10} y={cy + 4} textAnchor="middle" fontSize={10} fill="#9ca3af">W</text>
</g>
);
}
export default function SolarPanelMap({
activePanels,
roofSegmentStats,
panelWidthMeters,
panelHeightMeters,
buildingCenter,
}: SolarPanelMapProps) {
const mPerLng = 111320 * Math.cos((buildingCenter.latitude * Math.PI) / 180);
const mPerLat = 111320;
const cx = SVG_W / 2;
const cy = SVG_H / 2;
const scale = useMemo(() => {
const allM = [
...activePanels.map((p) => ({
x: (p.center.longitude - buildingCenter.longitude) * mPerLng,
y: (p.center.latitude - buildingCenter.latitude) * mPerLat,
})),
...roofSegmentStats.map((s) => ({
x: (s.center.longitude - buildingCenter.longitude) * mPerLng,
y: (s.center.latitude - buildingCenter.latitude) * mPerLat,
})),
];
if (allM.length === 0) return 20;
const maxR = Math.max(...allM.map((p) => Math.sqrt(p.x ** 2 + p.y ** 2)), 1);
const halfSize = Math.max(
...roofSegmentStats.map((s) => Math.sqrt(s.stats.groundAreaMeters2) / 2),
panelWidthMeters,
panelHeightMeters,
1
);
const drawR = Math.min(SVG_W, SVG_H) / 2 - PAD;
return drawR / (maxR + halfSize);
}, [activePanels, roofSegmentStats, buildingCenter, mPerLng, mPerLat, panelWidthMeters, panelHeightMeters]);
const segmentData = useMemo(
() =>
roofSegmentStats.map((seg, i) => ({
key: i,
px: cx + (seg.center.longitude - buildingCenter.longitude) * mPerLng * scale,
py: cy - (seg.center.latitude - buildingCenter.latitude) * mPerLat * scale,
side: Math.sqrt(seg.stats.groundAreaMeters2) * scale,
az: seg.azimuthDegrees,
direction: getCardinal(seg.azimuthDegrees),
style: SEGMENT_STYLES[i % SEGMENT_STYLES.length],
segmentIndex: seg.segmentIndex,
})),
[roofSegmentStats, buildingCenter, mPerLng, mPerLat, scale, cx, cy]
);
const panelData = useMemo(
() =>
activePanels.map((panel, i) => {
const seg = roofSegmentStats.find((s) => s.segmentIndex === panel.segmentIndex);
const az = seg?.azimuthDegrees ?? 180;
const isLandscape = panel.orientation === "LANDSCAPE";
const pw = (isLandscape ? panelHeightMeters : panelWidthMeters) * scale;
const ph = (isLandscape ? panelWidthMeters : panelHeightMeters) * scale;
return {
key: i,
px: cx + (panel.center.longitude - buildingCenter.longitude) * mPerLng * scale,
py: cy - (panel.center.latitude - buildingCenter.latitude) * mPerLat * scale,
pw,
ph,
az,
color: PANEL_COLORS[panel.segmentIndex % PANEL_COLORS.length],
};
}),
[activePanels, roofSegmentStats, buildingCenter, mPerLng, mPerLat, scale, cx, cy, panelWidthMeters, panelHeightMeters]
);
return (
<div className="rounded-lg overflow-hidden border border-gray-200">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
width="100%"
style={{ display: "block", background: "#f8fafc" }}
aria-label="Roof schematic with solar panels"
>
{/* Subtle grid */}
{Array.from({ length: 21 }, (_, i) => i - 10).map((i) => (
<g key={i}>
<line x1={0} y1={cy + i * 20} x2={SVG_W} y2={cy + i * 20} stroke="#e5e7eb" strokeWidth={0.4} />
<line x1={cx + i * 20} y1={0} x2={cx + i * 20} y2={SVG_H} stroke="#e5e7eb" strokeWidth={0.4} />
</g>
))}
{/* Roof segments */}
{segmentData.map((seg) => (
<g key={seg.key} transform={`rotate(${seg.az + 180}, ${seg.px}, ${seg.py})`}>
<rect
x={seg.px - seg.side / 2}
y={seg.py - seg.side / 2}
width={seg.side}
height={seg.side}
fill={seg.style.fill}
stroke={seg.style.stroke}
strokeWidth={2}
rx={3}
/>
{/* Label counter-rotates so it's always readable */}
<text
x={seg.px}
y={seg.py - seg.side / 2 + Math.max(14, seg.side * 0.18)}
textAnchor="middle"
fontSize={Math.min(13, Math.max(9, seg.side * 0.16))}
fontWeight="600"
fill={seg.style.text}
transform={`rotate(${-(seg.az + 180)}, ${seg.px}, ${seg.py})`}
>
{seg.direction}-facing
</text>
</g>
))}
{/* Solar panels */}
{panelData.map((p) => (
<g key={p.key} transform={`rotate(${p.az + 180}, ${p.px}, ${p.py})`}>
<rect
x={p.px - p.pw / 2}
y={p.py - p.ph / 2}
width={p.pw}
height={p.ph}
fill={p.color}
fillOpacity={0.9}
stroke="white"
strokeWidth={0.6}
rx={1}
/>
</g>
))}
{/* Compass rose — top right */}
<CompassRose cx={SVG_W - 52} cy={52} r={26} />
{/* Legend — bottom left */}
<g>
{segmentData.map((seg, i) => (
<g key={i} transform={`translate(12, ${SVG_H - 14 - (segmentData.length - 1 - i) * 20})`}>
<rect width={12} height={12} y={-11} fill={seg.style.fill} stroke={seg.style.stroke} strokeWidth={1.5} rx={2} />
<rect width={6} height={10} x={3} y={-10} fill={PANEL_COLORS[i % PANEL_COLORS.length]} fillOpacity={0.9} rx={1} />
<text x={18} y={0} fontSize={11} fill="#374151">
Segment {seg.segmentIndex + 1} {seg.direction}-facing
</text>
</g>
))}
</g>
</svg>
</div>
);
}

View file

@ -0,0 +1,213 @@
"use client";
import { useMemo } from "react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Separator } from "@/app/shadcn_components/ui/separator";
interface SolarPanelConfig {
panelsCount: number;
yearlyEnergyDcKwh: number;
}
interface SolarConfigTableProps {
solarPanelConfigs: SolarPanelConfig[];
panelCapacityWatts: number;
panelLifetimeYears: number;
panelWidthMeters: number;
panelHeightMeters: number;
}
function ChartTooltip({
payload,
active,
label,
}: {
payload?: { name: string; value: number; color: string }[];
active?: boolean;
label?: string | number;
}) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2.5 text-sm min-w-[160px]">
<p className="font-semibold text-gray-700 mb-1.5 border-b border-gray-100 pb-1.5">
{label} panels
</p>
{payload.map((item, i) => (
<div key={i} className="flex items-center justify-between gap-3 py-0.5">
<div className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-500 text-xs">{item.name}</span>
</div>
<span className="font-semibold text-gray-800 text-xs">
{Math.round(item.value).toLocaleString()} kWh
</span>
</div>
))}
</div>
);
}
export default function SolarConfigTable({
solarPanelConfigs,
panelCapacityWatts,
panelLifetimeYears,
panelWidthMeters,
panelHeightMeters,
}: SolarConfigTableProps) {
const chartData = useMemo(
() =>
solarPanelConfigs.map((cfg) => ({
panels: cfg.panelsCount,
"Annual output": cfg.yearlyEnergyDcKwh,
})),
[solarPanelConfigs]
);
return (
<div className="space-y-8">
{/* Efficiency curve */}
<div>
<p className="text-sm font-semibold text-brandblue mb-0.5">
Solar output vs. number of panels
</p>
<p className="text-xs text-gray-400 mb-5">
A curve that flattens early indicates diminishing returns as panels
are placed on less optimal roof faces.
</p>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={chartData} margin={{ top: 10, right: 16, left: 0, bottom: 24 }}>
<defs>
<linearGradient id="solarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3943b7" stopOpacity={0.18} />
<stop offset="95%" stopColor="#3943b7" stopOpacity={0.01} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis
dataKey="panels"
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
label={{
value: "Number of panels",
position: "insideBottom",
offset: -12,
fontSize: 10,
fill: "#9ca3af",
}}
height={40}
/>
<YAxis
width={52}
tick={{ fontSize: 10, fill: "#9ca3af" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`}
/>
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "#3943b7", strokeWidth: 1, strokeDasharray: "4 2" }} />
<Area
type="monotone"
dataKey="Annual output"
stroke="#3943b7"
strokeWidth={2}
fill="url(#solarGradient)"
dot={false}
activeDot={{ r: 4, fill: "#3943b7", strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<Separator />
{/* Configurations table */}
<div>
<p className="text-sm font-semibold text-brandblue mb-0.5">
All modelled configurations
</p>
<p className="text-xs text-gray-400 mb-4">
Every array size considered, from the smallest viable installation to the
maximum possible for this property.
</p>
<div className="rounded-lg border border-gray-200 overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/80">
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide">Panels</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">Capacity</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">Roof area</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">Annual output</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">
Lifetime output
<span className="ml-1 font-normal text-gray-400 normal-case">
({panelLifetimeYears} yr)
</span>
</TableHead>
<TableHead className="font-semibold text-gray-500 text-xs uppercase tracking-wide text-right">kWh / kWp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow
key={cfg.panelsCount}
className={isEven ? "bg-white" : "bg-gray-50/40"}
>
<TableCell className="font-semibold text-brandblue">
{cfg.panelsCount}
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{capacityKwp.toFixed(1)}
<span className="ml-1 text-xs text-gray-400">kWp</span>
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{areaM2.toFixed(1)}
<span className="ml-1 text-xs text-gray-400">m²</span>
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{annualKwh.toLocaleString()}
<span className="ml-1 text-xs text-gray-400">kWh</span>
</TableCell>
<TableCell className="text-right text-gray-600 tabular-nums">
{lifetimeKwh.toLocaleString()}
<span className="ml-1 text-xs text-gray-400">kWh</span>
</TableCell>
<TableCell className="text-right text-gray-400 tabular-nums text-xs">
{efficiency.toLocaleString()}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View file

@ -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: () => (
<div className="space-y-4">
<div className="h-52 bg-gray-100 animate-pulse rounded-lg" />
<div className="h-64 bg-gray-100 animate-pulse rounded-lg" />
</div>
),
});
export default function SolarSimulationWrapper(
props: ComponentProps<typeof SolarSimulation>
) {
return <SolarConfigDynamic {...props} />;
}

View file

@ -155,6 +155,7 @@ module.exports = {
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
manrope: ["var(--font-manrope)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {