mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
re-vamping ara ui
This commit is contained in:
parent
ff07111edd
commit
b9d5166e82
17 changed files with 2313 additions and 842 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ReactQueryProvider } from "./ReactQueryProvider";
|
|||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { cache } from "react";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Inter, Manrope } from "next/font/google";
|
||||
import { Toaster } from "@/app/shadcn_components/ui/toaster";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import type { Metadata } from "next";
|
||||
|
|
@ -16,6 +16,12 @@ const inter = Inter({
|
|||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-manrope",
|
||||
weight: ["400", "500", "700", "800"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Ara",
|
||||
description: "Ara is Domna’s portfolio intelligence platform that turns housing stock data into clear, costed retrofit and investment plans.",
|
||||
|
|
@ -61,7 +67,7 @@ export default async function RootLayout({
|
|||
const userImage = session?.user?.image;
|
||||
|
||||
return (
|
||||
<html lang="en" className={inter.className}>
|
||||
<html lang="en" className={`${inter.className} ${manrope.variable}`}>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<Provider>
|
||||
<ReactQueryProvider>
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/app/shadcn_components/ui/tooltip";
|
||||
|
||||
const EPC_BANDS = [
|
||||
{ band: "A", range: "92–100", color: "#117d58", desc: "Exceptional, near-zero energy bills, usually new-builds or eco-homes." },
|
||||
{ band: "B", range: "81–91", color: "#2da55c", desc: "Very efficient, often featuring solar panels, high-grade insulation, and modern heating." },
|
||||
{ band: "C", range: "69–80", color: "#8dbd40", desc: "Good, above-average efficiency; common target for retrofitting existing homes." },
|
||||
{ band: "D", range: "55–68", color: "#f7cd14", desc: "Average, the typical rating for many homes in the UK." },
|
||||
{ band: "E", range: "39–54", color: "#f3a96a", desc: "Below average, likely requires better insulation and boiler upgrades." },
|
||||
{ band: "F", range: "21–38", color: "#ef8026", desc: "Poor, high energy costs and lower energy performance." },
|
||||
{ band: "G", range: "1–20", color: "#e41e3b", desc: "Very poor, least efficient, high energy costs." },
|
||||
];
|
||||
|
||||
export function EpcInfoTooltip() {
|
||||
return (
|
||||
<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 (1–100)</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} m²` },
|
||||
{ label: "Ground area", value: `${groundAreaMeters2.toFixed(1)} m²` },
|
||||
{ label: "Pitch", value: `${pitchDegrees.toFixed(1)}°` },
|
||||
{ label: "Azimuth", value: `${azimuthDegrees.toFixed(1)}°` },
|
||||
{ label: "Height", value: `${planeHeightAtCenterMeters.toFixed(1)} m` },
|
||||
peakSunshine !== null
|
||||
? { label: "Peak (P90)", value: `${Math.round(peakSunshine)} hrs` }
|
||||
: { label: "", value: "" },
|
||||
].map(({ label, value }, i) =>
|
||||
label ? (
|
||||
<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)} m²` : "Unknown" },
|
||||
{ feature: "Age", description: propertyMeta.yearBuilt != null ? String(propertyMeta.yearBuilt) : "Unknown" },
|
||||
];
|
||||
const generalFeatures = [
|
||||
...fundamentalDetails,
|
||||
...formatGeneralFeatures(conditionReport, propertyMeta.propertyType)
|
||||
.filter((f) => !["Mains gas", "Year built", "Property type", "Floor area", "Habitable rooms"].includes(f.feature))
|
||||
.slice(0, 4),
|
||||
];
|
||||
|
||||
<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)} m²`,
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
"0–6 months": [],
|
||||
"6–12 months": [],
|
||||
">12 months": [],
|
||||
};
|
||||
for (const e of entries) {
|
||||
const diffMs = e.expiry.getTime() - Date.now();
|
||||
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
|
||||
if (e.overdue) groups.Overdue.push(e);
|
||||
else if (diffMonths <= 6) groups["0–6 months"].push(e);
|
||||
else if (diffMonths <= 12) groups["6–12 months"].push(e);
|
||||
else groups[">12 months"].push(e);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function CriterionPanel({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: { sub_variable: string; result: string }[];
|
||||
}) {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const order = { fail: 0, no_data: 1, pass: 2 };
|
||||
return (order[a.result as keyof typeof order] ?? 1) - (order[b.result as keyof typeof order] ?? 1);
|
||||
});
|
||||
|
||||
const failCount = items.filter((i) => i.result === "fail").length;
|
||||
const passCount = items.filter((i) => i.result === "pass").length;
|
||||
|
||||
return (
|
||||
<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",
|
||||
},
|
||||
"0–6 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: "0–6 months",
|
||||
},
|
||||
"6–12 months": {
|
||||
border: "border-yellow-300",
|
||||
bg: "bg-yellow-50",
|
||||
badge: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
icon: <Hourglass className="w-3.5 h-3.5 text-yellow-600" />,
|
||||
label: "6–12 months",
|
||||
},
|
||||
">12 months": {
|
||||
border: "border-emerald-300",
|
||||
bg: "bg-emerald-50",
|
||||
badge: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||
icon: <CheckCircleIcon className="w-3.5 h-3.5 text-emerald-600" />,
|
||||
label: ">12 months",
|
||||
},
|
||||
};
|
||||
|
||||
const order = ["Overdue", "0–6 months", "6–12 months", ">12 months"] as const;
|
||||
const hasAny = order.some((k) => groups[k].length > 0);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
"0–6 months": "bg-orange-500",
|
||||
"6–12 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: [],
|
||||
"0–6 months": [],
|
||||
"6–12 months": [],
|
||||
">12 months": [],
|
||||
};
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item.expiry_date || !item.install_date) return;
|
||||
const expiry = new Date(item.expiry_date);
|
||||
const install = new Date(item.install_date);
|
||||
const diffMs = expiry.getTime() - today.getTime();
|
||||
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
|
||||
|
||||
const years = Math.floor(Math.abs(diffMonths) / 12);
|
||||
const months = Math.floor(Math.abs(diffMonths) % 12);
|
||||
|
||||
let remaining = "";
|
||||
let overdue = false;
|
||||
if (diffMs < 0) {
|
||||
overdue = true;
|
||||
remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`;
|
||||
} else {
|
||||
remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
sub_variable: SUB_ITEMS_TEXT[item.sub_variable] ?? item.sub_variable,
|
||||
expiry,
|
||||
install,
|
||||
remaining,
|
||||
overdue,
|
||||
};
|
||||
|
||||
if (diffMs < 0) groups.Overdue.push(entry);
|
||||
else if (diffMonths <= 6) groups["0–6 months"].push(entry);
|
||||
else if (diffMonths <= 12) groups["6–12 months"].push(entry);
|
||||
else groups[">12 months"].push(entry);
|
||||
});
|
||||
|
||||
// sort within each group
|
||||
Object.values(groups).forEach((comps) =>
|
||||
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()),
|
||||
);
|
||||
|
||||
const groupOrder: (keyof typeof groups)[] = [
|
||||
"Overdue",
|
||||
"0–6 months",
|
||||
"6–12 months",
|
||||
">12 months",
|
||||
];
|
||||
|
||||
// urgency → card highlight color + icon
|
||||
const cardStyles: Record<string, { border: string; icon: ReactNode }> = {
|
||||
Overdue: {
|
||||
border: "border-l-4 border-red-600",
|
||||
icon: <AlertTriangle className="w-4 h-4 text-red-600" />,
|
||||
},
|
||||
"0–6 months": {
|
||||
border: "border-l-4 border-orange-500",
|
||||
icon: <Clock className="w-4 h-4 text-orange-500" />,
|
||||
},
|
||||
"6–12 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 }>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -155,6 +155,7 @@ module.exports = {
|
|||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
manrope: ["var(--font-manrope)", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue