diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx index b76294c9..da2810b8 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/DecentHomesSummary.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import type { ReactNode } from "react"; import { CheckCircleIcon, @@ -18,7 +17,6 @@ import { XCircleIcon as XCircleSolid, } from "@heroicons/react/24/solid"; import { AlertTriangle, Hourglass } from "lucide-react"; -import { Badge } from "@/app/shadcn_components/ui/badge"; // ── Constants ───────────────────────────────────────────────────────────────── @@ -108,8 +106,6 @@ const SUB_ITEMS_TEXT: Record = { // ── Types ───────────────────────────────────────────────────────────────────── -type CriterionKey = "A" | "B" | "C" | "D" | "replacements"; - type MetaItem = { criteria: string; sub_variable: string; @@ -128,10 +124,10 @@ type ReplacementEntry = { // ── Helpers ─────────────────────────────────────────────────────────────────── -function getStatusIcon(status: string, size = "w-4 h-4") { - if (status === "pass") return ; - if (status === "fail") return ; - return ; +function getStatusIcon(status: string) { + if (status === "pass") return ; + if (status === "fail") return ; + return ; } function getStatusDot(status: string) { @@ -155,6 +151,31 @@ function getOverallLabel(status: string): string { return "Information Missing"; } +function getCriterionStatusBadge(status: string) { + if (status === "pass") { + return ( + + + Pass + + ); + } + if (status === "fail") { + return ( + + + Fail + + ); + } + return ( + + + No Data + + ); +} + function parseReplacements(items: MetaItem[]): ReplacementEntry[] { const today = new Date(); return items @@ -201,13 +222,19 @@ function groupReplacements(entries: ReplacementEntry[]) { // ── Sub-components ──────────────────────────────────────────────────────────── -function CriterionPanel({ - title, +function CriterionCard({ + letter, + label, description, + icon, + status, items, }: { - title: string; + letter: string; + label: string; description: string; + icon: ReactNode; + status: string; items: { sub_variable: string; result: string }[]; }) { const sorted = [...items].sort((a, b) => { @@ -217,80 +244,108 @@ function CriterionPanel({ const failCount = items.filter((i) => i.result === "fail").length; const passCount = items.filter((i) => i.result === "pass").length; + const notAssessedCount = items.length - passCount - failCount; return ( -
-
-

{title}

-

{description}

-
- - {/* Summary pills */} -
- - - {passCount} Pass - - - - {failCount} Fail - - - - {items.length - passCount - failCount} Not assessed - -
- - {/* Items list */} -
- {sorted.map((item, idx) => ( -
- - {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} - - {getStatusIcon(item.result)} +
+ {/* Card header */} +
+
+
+ {icon}
- ))} +
+

+ Criterion {letter} +

+

{label}

+
+
+ {getCriterionStatusBadge(status)} +
+ + {/* Description */} +

{description}

+ + {/* Stats row */} +
+ + + {passCount} pass + + · + + + {failCount} fail + + {notAssessedCount > 0 && ( + <> + · + + + {notAssessedCount} not assessed + + + )} +
+ + {/* Scrollable item list */} +
+
+ {sorted.map((item, idx) => ( +
+ + {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} + + {getStatusIcon(item.result)} +
+ ))} +
+ {/* Gradient fade to indicate more items below */} +
); } -function ReplacementsPanel({ items }: { items: MetaItem[] }) { +function ReplacementsSection({ items }: { items: MetaItem[] }) { const entries = parseReplacements(items); const groups = groupReplacements(entries); const urgencyConfig: Record = { Overdue: { - border: "border-red-300", - bg: "bg-red-50", + border: "border-red-200", + headerBg: "bg-red-50", badge: "bg-red-100 text-red-800 border-red-200", icon: , label: "Overdue", }, "0–6 months": { - border: "border-orange-300", - bg: "bg-orange-50", + border: "border-orange-200", + headerBg: "bg-orange-50", badge: "bg-orange-100 text-orange-800 border-orange-200", icon: , label: "0–6 months", }, "6–12 months": { - border: "border-yellow-300", - bg: "bg-yellow-50", + border: "border-yellow-200", + headerBg: "bg-yellow-50", badge: "bg-yellow-100 text-yellow-800 border-yellow-200", icon: , label: "6–12 months", }, ">12 months": { - border: "border-emerald-300", - bg: "bg-emerald-50", + border: "border-emerald-200", + headerBg: "bg-emerald-50", badge: "bg-emerald-100 text-emerald-800 border-emerald-200", icon: , label: ">12 months", @@ -298,45 +353,33 @@ function ReplacementsPanel({ items }: { items: MetaItem[] }) { }; const order = ["Overdue", "0–6 months", "6–12 months", ">12 months"] as const; - const hasAny = order.some((k) => groups[k].length > 0); return ( -
-
-

Component Replacements

-

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

-
- - {!hasAny && ( -
- -

No replacement data available.

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

None

+

None

) : ( list.map((entry, idx) => (
@@ -356,18 +399,21 @@ function ReplacementsPanel({ items }: { items: MetaItem[] }) { )) )}
+ {list.length > 0 && ( +
+ )}
- ); - })} -
+
+ ); + })}
); } -// ── Main export ─────────────────────────────────────────────────────────────── +// ── Data ────────────────────────────────────────────────────────────────────── const CRITERIA: { - key: CriterionKey; + key: "A" | "B" | "C" | "D"; letter: string; label: string; description: string; @@ -408,6 +454,8 @@ const CRITERIA: { }, ]; +// ── Main export ─────────────────────────────────────────────────────────────── + export default function DecentHomesSummary({ decentHomes, decentHomesMeta, @@ -423,8 +471,6 @@ export default function DecentHomesSummary({ }; decentHomesMeta: MetaItem[]; }) { - const [selected, setSelected] = useState("A"); - const criteriaGroups: Record = { A: [], B: [], C: [], D: [], }; @@ -460,122 +506,93 @@ export default function DecentHomesSummary({ }; return ( -
+
- {/* ── Hero ─────────────────────────────────────────────────────────── */} -
-
+ {/* ── Page header ──────────────────────────────────────────────────── */} +
+

+ Housing Standards +

+

+ Decent Homes Assessment +

+
+ + {/* ── Hero card ────────────────────────────────────────────────────── */} +
+
{overallStatus === "pass" ? ( - + ) : overallStatus === "fail" ? ( - + ) : ( - + )}
-

{overallLabel}

-

+

+ {overallLabel} +

+

Decent Homes Standard assessment · Last updated {lastUpdated}

{/* Criteria summary pills */} -
+
{CRITERIA.map((c) => { const s = criterionStatus[c.letter]; return ( -
+
{getStatusDot(s)} Criterion {c.letter} + {c.label}
); })}
- {/* ── Body: sidebar + content ──────────────────────────────────────── */} -
- - {/* Sidebar */} -
- {CRITERIA.map((c) => { - const s = criterionStatus[c.letter]; - const active = selected === c.key; - return ( - - ); - })} - - {/* Replacements */} - + {/* ── Criteria cards ───────────────────────────────────────────────── */} +
+
+

+ Assessment Criteria +

+
+ {CRITERIA.map((c) => ( + + ))} +
+
- {/* Content */} -
- {selected === "replacements" ? ( - - ) : ( - (() => { - const c = CRITERIA.find((x) => x.key === selected)!; - return ( - - ); - })() + {/* ── Replacements section ─────────────────────────────────────────── */} +
+
+

+ Component Replacements +

+ {overdueCount > 0 && ( + + {overdueCount} overdue + )}
-
+ + +
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx index f40fa076..f9c3643c 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/PlanCard.tsx @@ -1,18 +1,17 @@ "use client"; import { useState } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { - ArrowTrendingUpIcon, - CalendarIcon, - BanknotesIcon, - ArrowRightIcon, - EllipsisVerticalIcon, -} from "@heroicons/react/24/outline"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { EllipsisVerticalIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { useRouter, usePathname } from "next/navigation"; +import { formatNumber } from "@/app/utils"; -import { getEpcColorClass, formatNumber } from "@/app/utils"; - +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/app/shadcn_components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -20,7 +19,6 @@ import { DialogTitle, DialogFooter, } from "@/app/shadcn_components/ui/dialog"; - import { Table, TableBody, @@ -29,39 +27,17 @@ import { TableHeader, TableRow, } from "@/app/shadcn_components/ui/table"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/app/shadcn_components/ui/dropdown-menu"; - import { Button } from "@/app/shadcn_components/ui/button"; -/* ---------------------------------------- - Types ------------------------------------------ */ -type DeletionPreviewRow = { - table: string; - count: number; -}; +type DeletionPreviewRow = { table: string; count: number }; -/* ---------------------------------------- - Fetchers ------------------------------------------ */ -async function fetchPlanDeletionPreview( - planId: string -): Promise { +async function fetchPlanDeletionPreview(planId: string): Promise { const res = await fetch(`/api/plan/${planId}/delete/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, }); - if (!res.ok) throw new Error("Failed to load deletion preview"); - - const json = await res.json(); - return json.preview; + return (await res.json()).preview; } async function confirmPlanDeletion(planId: string): Promise { @@ -70,42 +46,9 @@ async function confirmPlanDeletion(planId: string): Promise { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm: true }), }); - - if (!res.ok) { - const msg = await res.text().catch(() => ""); - throw new Error(msg || "Failed to delete plan"); - } + if (!res.ok) throw new Error("Failed to delete plan"); } -async function setDefaultPlan(planId: string): Promise { - const res = await fetch(`/api/plan/${planId}/set-default`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - - if (!res.ok) throw new Error("Failed to set default plan"); -} - -/* ---------------------------------------- - EPC Badge ------------------------------------------ */ -function EpcBadge({ rating, label }: { rating: string; label: string }) { - const colorClass = getEpcColorClass(rating); - return ( -
- {label} - - {rating} - -
- ); -} - -/* ---------------------------------------- - Component ------------------------------------------ */ export default function PlanCard({ expectedEpcRating, currentEpcRating, @@ -125,12 +68,13 @@ export default function PlanCard({ planId: string; isDefault: boolean; }) { - const [deleteOpen, setDeleteOpen] = useState(false); + const [open, setOpen] = useState(false); const [setDefaultOpen, setSetDefaultOpen] = useState(false); + const [settingDefault, setSettingDefault] = useState(false); + const queryClient = useQueryClient(); const router = useRouter(); const pathname = usePathname(); - /* -------- Preview query -------- */ const { data: preview = [], isLoading, @@ -138,163 +82,125 @@ export default function PlanCard({ } = useQuery({ queryKey: ["planDeletionPreview", planId], queryFn: () => fetchPlanDeletionPreview(planId), - enabled: deleteOpen, + enabled: open, }); - /* -------- Delete mutation -------- */ const deleteMutation = useMutation({ mutationFn: () => confirmPlanDeletion(planId), onSuccess: () => { - setDeleteOpen(false); + setOpen(false); + queryClient.invalidateQueries({ queryKey: ["plans"] }); router.refresh(); }, }); - /* -------- Set default mutation -------- */ - const setDefaultMutation = useMutation({ - mutationFn: () => setDefaultPlan(planId), - onSuccess: () => { - setSetDefaultOpen(false); + async function handleSetDefault() { + setSettingDefault(true); + setSetDefaultOpen(false); + try { + await fetch(`/api/plan/${planId}/set-default`, { method: "POST" }); router.refresh(); - }, - }); - - const createdDate = new Date(createdAt).toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); + } finally { + setSettingDefault(false); + } + } const sapImprovement = Math.round((totalSapPoints + Number.EPSILON) * 100) / 100; return ( <> -
- - {/* Header */} -
-
-

Retrofit Plan

-

+
+
+ {/* Title row */} +
+

{planName ?? "Unnamed Plan"}

-
- - - - - - - {!isDefault && ( + + + + + setSetDefaultOpen(true)} + disabled={settingDefault} > - Set as Default + {settingDefault ? "Setting…" : "Set as Default"} - )} - setDeleteOpen(true)} - > - Delete Plan - - - -
- - {/* Body */} -
- - {/* EPC progression */} -
- -
-
- -
-
- + setOpen(true)} + > + Delete Plan + + +
- {/* Stats row */} -
-
-
- - Cost -
- - £{formatNumber(totalEstimatedCost)} - + {/* Stats */} +
+
+ Expected EPC + {expectedEpcRating}
- -
-
- - SAP -
- - +{sapImprovement} - +
+ Investment + £{formatNumber(totalEstimatedCost)}
- -
-
- - Date -
- {createdDate} +
+ SAP Gain + +{sapImprovement} pts
- - {/* CTA */} -
+ + {/* CTA */} +
- {/* Set default confirmation dialog */} + {/* Set default confirmation modal */} - + - Set as default plan? + + Change default plan? + -

- {planName ?? "This plan"} will become the default plan for this property. The current default plan will be moved to the secondary list. +

+ {planName ?? "This plan"} will + become the recommended strategy shown at the top of the page. You can change it again + at any time.

- -
{/* Delete preview modal */} - + Delete plan @@ -326,11 +232,7 @@ export default function PlanCard({ )} -
- {/* Right: visual panel */} -
-
-
-
-
-
-
- + {/* Right: image */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Modern architectural house exterior +
+
+ + {/* ── Impact card ─────────────────────────────── */} +
+
+

Impact

+ + {/* CO2 */} +
+
+ + + + +
+
+

+ Annual CO₂ Reduction +

+
+

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

+ {carsEquivalent != null && carsEquivalent > 0 && ( +
+ +
+
+
+
+ + + + + + +
+

+ Equivalent Impact +

+
+

+ Like taking{" "} + + ~{carsEquivalent} car + {carsEquivalent !== 1 ? "s" : ""} + {" "} + off the road for a year — based on the avg. UK car + emitting 1.47 t CO₂/yr. +

+
+ {/* Caret */} +
+
+
+
+
+
+
+ )} +
-

- Curated retrofit strategy -

-

- Engineered to maximise energy performance and long-term value. -

+ + {/* Bill savings */} +
+
+ + + + +
+
+

+ Bill Savings (Est.) +

+

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

+
+
+ + {/* Valuation uplift */} + {(valuationIncreaseLowerBound != null || + valuationIncreaseUpperBound != null) && ( +
+
+ + + + + +
+
+

+ Valuation Uplift (Est.) +

+

+ {valuationIncreaseLowerBound != null && + valuationIncreaseUpperBound != null + ? `${formatNumber(valuationIncreaseLowerBound * 100)}% – ${formatNumber(valuationIncreaseUpperBound * 100)}%` + : valuationIncreaseLowerBound != null + ? `${formatNumber(valuationIncreaseLowerBound * 100)}%+` + : `Up to ${formatNumber(valuationIncreaseUpperBound! * 100)}%`} +

+
+
+ )}
@@ -283,7 +429,9 @@ export default function PlanHeroCard({ {previewLoading ? (

Loading deletion preview…

) : previewError ? ( -

Failed to load deletion preview

+

+ Failed to load deletion preview +

) : (
@@ -296,8 +444,12 @@ export default function PlanHeroCard({ {preview.map((row) => ( - {row.table} - {row.count} + + {row.table} + + + {row.count} + ))} @@ -305,10 +457,18 @@ export default function PlanHeroCard({ )} - - diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx index 16896edc..735c5638 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx @@ -20,17 +20,18 @@ export default async function RecommendationPlans(props: {

Retrofit Strategy

-

- Retrofit Plans -

-

No plans yet

-

Retrofit plans will appear here once they have been generated.

+

+ No plans yet +

+

+ Retrofit plans will appear here once they have been generated. +

@@ -44,21 +45,18 @@ export default async function RecommendationPlans(props: { propertyMeta.currentSapPoints; const expectedSapPoints = Math.min( propertyMeta.currentSapPoints + totalSapPoints, - 100 + 100, ); const expectedEpcRating = sapToEpc(expectedSapPoints); return { totalEstimatedCost, totalSapPoints, expectedEpcRating }; } - /* Identify the default plan (fallback: first plan) */ const defaultPlan = plans.find((p) => p.isDefault) ?? plans[0]; const otherPlans = plans.filter((p) => p.id !== defaultPlan.id); - const defaultMetrics = getPlanMetrics(defaultPlan); return (
- {/* Page header */}

@@ -69,7 +67,7 @@ export default async function RecommendationPlans(props: {

- {/* Hero — default plan */} + {/* Hero — default plan + carbon impact card */} - {/* Secondary plans */} - {otherPlans.length > 0 && ( -
+ {/* Secondary plans grid */} +
+ {otherPlans.length > 0 && (

Other Plans @@ -93,27 +97,36 @@ export default async function RecommendationPlans(props: { {otherPlans.length}

+ )} -
+
+ {/* Gradient fade to indicate overflow */} +
+
{otherPlans.map((plan) => { - const { totalEstimatedCost, totalSapPoints, expectedEpcRating } = getPlanMetrics(plan); + const { totalEstimatedCost, totalSapPoints, expectedEpcRating } = + getPlanMetrics(plan); return ( - + className="min-w-[300px] flex-shrink-0 snap-start" + > + +
); })}
-
- )} +
+ ); }