refactored decent homes screen
Some checks are pending
Next.js Build Check / build (push) Waiting to run

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-09 11:09:46 +00:00
parent 0c0529b234
commit e048850196
4 changed files with 604 additions and 512 deletions

View file

@ -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<string, string> = {
// ── 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 <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 getStatusIcon(status: string) {
if (status === "pass") return <CheckCircleSolid className="w-4 h-4 text-emerald-500 shrink-0" />;
if (status === "fail") return <XCircleSolid className="w-4 h-4 text-red-500 shrink-0" />;
return <MinusCircleIcon className="w-4 h-4 text-gray-400 shrink-0" />;
}
function getStatusDot(status: string) {
@ -155,6 +151,31 @@ function getOverallLabel(status: string): string {
return "Information Missing";
}
function getCriterionStatusBadge(status: string) {
if (status === "pass") {
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700">
<CheckCircleSolid className="w-3 h-3" />
Pass
</span>
);
}
if (status === "fail") {
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-red-50 border border-red-200 text-red-700">
<XCircleSolid className="w-3 h-3" />
Fail
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs font-semibold px-2 py-0.5 rounded-full bg-gray-50 border border-gray-200 text-gray-500">
<MinusCircleIcon className="w-3 h-3" />
No Data
</span>
);
}
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 (
<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 className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden flex flex-col">
{/* Card header */}
<div className="p-5 pb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brandblue flex items-center justify-center shrink-0">
<span className="text-white">{icon}</span>
</div>
))}
<div>
<p className="text-[10px] font-bold text-brandmidblue uppercase tracking-widest">
Criterion {letter}
</p>
<p className="font-manrope font-bold text-sm text-brandblue leading-tight">{label}</p>
</div>
</div>
{getCriterionStatusBadge(status)}
</div>
{/* Description */}
<p className="text-xs text-gray-400 px-5 pb-3 leading-relaxed">{description}</p>
{/* Stats row */}
<div className="flex items-center gap-3 px-5 pb-3">
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-700">
<CheckCircleSolid className="w-3 h-3" />
{passCount} pass
</span>
<span className="text-gray-200">·</span>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-red-600">
<XCircleSolid className="w-3 h-3" />
{failCount} fail
</span>
{notAssessedCount > 0 && (
<>
<span className="text-gray-200">·</span>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-gray-400">
<MinusCircleIcon className="w-3 h-3" />
{notAssessedCount} not assessed
</span>
</>
)}
</div>
{/* Scrollable item list */}
<div className="relative flex-1 border-t border-gray-100">
<div className="max-h-[320px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden divide-y divide-gray-100">
{sorted.map((item, idx) => (
<div
key={idx}
className="flex items-center justify-between px-5 py-2.5 hover:bg-gray-50 transition-colors"
>
<span className="text-xs text-gray-700 pr-3">
{DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
</span>
{getStatusIcon(item.result)}
</div>
))}
</div>
{/* Gradient fade to indicate more items below */}
<div className="pointer-events-none absolute bottom-0 inset-x-0 h-8 bg-gradient-to-t from-white to-transparent z-10" />
</div>
</div>
);
}
function ReplacementsPanel({ items }: { items: MetaItem[] }) {
function ReplacementsSection({ items }: { items: MetaItem[] }) {
const entries = parseReplacements(items);
const groups = groupReplacements(entries);
const urgencyConfig: Record<string, {
border: string;
bg: string;
headerBg: string;
badge: string;
icon: ReactNode;
label: string;
}> = {
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: <AlertTriangle className="w-3.5 h-3.5 text-red-600" />,
label: "Overdue",
},
"06 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: <ClockIcon className="w-3.5 h-3.5 text-orange-600" />,
label: "06 months",
},
"612 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: <Hourglass className="w-3.5 h-3.5 text-yellow-600" />,
label: "612 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: <CheckCircleIcon className="w-3.5 h-3.5 text-emerald-600" />,
label: ">12 months",
@ -298,45 +353,33 @@ function ReplacementsPanel({ items }: { items: MetaItem[] }) {
};
const order = ["Overdue", "06 months", "612 months", ">12 months"] as const;
const hasAny = order.some((k) => groups[k].length > 0);
return (
<div className="space-y-5">
<div>
<h3 className="text-base font-bold text-brandblue">Component Replacements</h3>
<p className="text-sm text-gray-400 mt-0.5">
Building components grouped by urgency based on their expected replacement dates.
</p>
</div>
{!hasAny && (
<div className="rounded-xl border border-gray-200 bg-gray-50 p-8 text-center">
<WrenchScrewdriverIcon className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No replacement data available.</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
{order.map((urgency) => {
const cfg = urgencyConfig[urgency];
const list = groups[urgency];
return (
<div key={urgency} className={`rounded-xl border ${cfg.border} overflow-hidden`}>
{/* Column header */}
<div className={`${cfg.bg} px-4 py-3 flex items-center justify-between border-b ${cfg.border}`}>
<div className="flex items-center gap-1.5">
{cfg.icon}
<span className="text-xs font-bold text-gray-700">{cfg.label}</span>
</div>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full border ${cfg.badge}`}>
{list.length}
</span>
<div 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-2xl border ${cfg.border} overflow-hidden flex flex-col bg-white shadow-sm`}
>
{/* Column header */}
<div className={`${cfg.headerBg} px-4 py-3 flex items-center justify-between border-b ${cfg.border} shrink-0`}>
<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">
{/* Scrollable body */}
<div className="relative flex-1">
<div className="max-h-[400px] overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden divide-y divide-gray-100">
{list.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-4">None</p>
<p className="text-xs text-gray-400 text-center py-6">None</p>
) : (
list.map((entry, idx) => (
<div key={idx} className="px-4 py-3">
@ -356,18 +399,21 @@ function ReplacementsPanel({ items }: { items: MetaItem[] }) {
))
)}
</div>
{list.length > 0 && (
<div className="pointer-events-none absolute bottom-0 inset-x-0 h-8 bg-gradient-to-t from-white to-transparent z-10" />
)}
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// ── 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<CriterionKey>("A");
const criteriaGroups: Record<string, { sub_variable: string; result: string }[]> = {
A: [], B: [], C: [], D: [],
};
@ -460,122 +506,93 @@ export default function DecentHomesSummary({
};
return (
<div className="py-6 space-y-6">
<div className="max-w-7xl mx-auto py-10 space-y-10">
{/* ── 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">
{/* ── Page header ──────────────────────────────────────────────────── */}
<header>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Housing Standards
</p>
<h1 className="font-manrope font-extrabold text-4xl text-brandblue tracking-tight">
Decent Homes Assessment
</h1>
</header>
{/* ── Hero card ────────────────────────────────────────────────────── */}
<div className={`rounded-2xl border ${overall.border} ${overall.bg} p-8 flex flex-wrap items-center justify-between gap-6`}>
<div className="flex items-center gap-4">
{overallStatus === "pass" ? (
<CheckCircleSolid className="w-8 h-8 text-emerald-500 shrink-0" />
<CheckCircleSolid className="w-12 h-12 text-emerald-500 shrink-0" />
) : overallStatus === "fail" ? (
<XCircleSolid className="w-8 h-8 text-red-500 shrink-0" />
<XCircleSolid className="w-12 h-12 text-red-500 shrink-0" />
) : (
<ExclamationTriangleIcon className="w-8 h-8 text-gray-400 shrink-0" />
<ExclamationTriangleIcon className="w-12 h-12 text-gray-400 shrink-0" />
)}
<div>
<p className="text-lg font-bold text-gray-900">{overallLabel}</p>
<p className="text-sm text-gray-500">
<p className="font-manrope font-extrabold text-2xl text-gray-900 tracking-tight">
{overallLabel}
</p>
<p className="text-sm text-gray-500 mt-0.5">
Decent Homes Standard assessment · Last updated {lastUpdated}
</p>
</div>
</div>
{/* Criteria summary pills */}
<div className="flex gap-2 flex-wrap">
<div className="flex gap-3 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">
<div
key={c.key}
className="flex items-center gap-2 text-xs font-semibold text-gray-600 bg-white border border-gray-200 rounded-full px-3 py-1.5 shadow-sm"
>
{getStatusDot(s)}
<span>Criterion {c.letter}</span>
<span className="text-gray-400 font-normal">{c.label}</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>
{/* ── Criteria cards ───────────────────────────────────────────────── */}
<section>
<div className="flex items-center gap-3 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Assessment Criteria
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{CRITERIA.map((c) => (
<CriterionCard
key={c.key}
letter={c.letter}
label={c.label}
description={c.description}
icon={c.icon}
status={criterionStatus[c.letter]}
items={criteriaGroups[c.letter]}
/>
))}
</div>
</section>
{/* 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]}
/>
);
})()
{/* ── Replacements section ─────────────────────────────────────────── */}
<section>
<div className="flex items-center gap-3 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Component Replacements
</p>
{overdueCount > 0 && (
<span className="bg-red-100 text-red-700 border border-red-200 text-[10px] font-bold px-2 py-0.5 rounded-full">
{overdueCount} overdue
</span>
)}
</div>
</div>
<ReplacementsSection items={decentHomesMeta} />
</section>
</div>
);
}

View file

@ -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<DeletionPreviewRow[]> {
async function fetchPlanDeletionPreview(planId: string): Promise<DeletionPreviewRow[]> {
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<void> {
@ -70,42 +46,9 @@ async function confirmPlanDeletion(planId: string): Promise<void> {
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<void> {
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 (
<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,
@ -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 (
<>
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm overflow-hidden hover:shadow-md transition-shadow w-72 shrink-0">
{/* Header */}
<div className="flex items-start justify-between px-5 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-sm font-bold text-brandblue leading-snug">
<div className="bg-gray-50 rounded-2xl p-8 flex flex-col justify-between hover:shadow-md transition-shadow border border-gray-100 min-h-[220px]">
<div>
{/* Title row */}
<div className="flex justify-between items-start mb-6">
<h3 className="font-manrope font-extrabold text-2xl text-brandblue tracking-tight pr-4">
{planName ?? "Unnamed Plan"}
</h3>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="rounded-lg p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 focus:outline-none transition"
aria-label="Plan options"
>
<EllipsisVerticalIcon className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isDefault && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex-shrink-0 rounded-lg p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-200 focus:outline-none transition"
aria-label="Plan options"
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onSelect={() => setSetDefaultOpen(true)}
disabled={settingDefault}
>
Set as Default
{settingDefault ? "Setting…" : "Set as Default"}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 cursor-pointer"
onSelect={() => setDeleteOpen(true)}
>
Delete Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Body */}
<div className="px-5 py-4 flex flex-col gap-4">
{/* EPC progression */}
<div className="flex items-center gap-3">
<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>
<EpcBadge rating={expectedEpcRating} label="Expected" />
<DropdownMenuItem
className="text-red-600 focus:text-red-600 focus:bg-red-50 cursor-pointer"
onSelect={() => setOpen(true)}
>
Delete Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-2.5 py-2">
<div className="flex items-center gap-1">
<BanknotesIcon className="w-3 h-3 text-gray-400 shrink-0" />
<span className="text-[9px] uppercase tracking-wide text-gray-400 font-medium">Cost</span>
</div>
<span className="text-xs font-bold text-gray-800 tabular-nums">
£{formatNumber(totalEstimatedCost)}
</span>
{/* Stats */}
<div className="space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400 font-medium">Expected EPC</span>
<span className="font-bold text-brandblue">{expectedEpcRating}</span>
</div>
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-2.5 py-2">
<div className="flex items-center gap-1">
<ArrowTrendingUpIcon className="w-3 h-3 text-gray-400 shrink-0" />
<span className="text-[9px] uppercase tracking-wide text-gray-400 font-medium">SAP</span>
</div>
<span className="text-xs font-bold text-gray-800 tabular-nums">
+{sapImprovement}
</span>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400 font-medium">Investment</span>
<span className="font-bold text-brandblue">£{formatNumber(totalEstimatedCost)}</span>
</div>
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-2.5 py-2">
<div className="flex items-center gap-1">
<CalendarIcon className="w-3 h-3 text-gray-400 shrink-0" />
<span className="text-[9px] uppercase tracking-wide text-gray-400 font-medium">Date</span>
</div>
<span className="text-[10px] font-semibold text-gray-700">{createdDate}</span>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400 font-medium">SAP Gain</span>
<span className="font-bold text-brandblue">+{sapImprovement} pts</span>
</div>
</div>
{/* 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>
{/* CTA */}
<button
type="button"
onClick={() => router.push(`${pathname}/${planId}`)}
className="mt-8 w-full text-brandmidblue font-manrope font-bold text-sm text-left flex items-center gap-2 hover:gap-3 transition-all group"
>
View Details
<ArrowRightIcon className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
</div>
{/* Set default confirmation dialog */}
{/* Set default confirmation modal */}
<Dialog open={setDefaultOpen} onOpenChange={setSetDefaultOpen}>
<DialogContent className="max-w-sm">
<DialogContent className="max-w-md">
<ModalHeader>
<DialogTitle>Set as default plan?</DialogTitle>
<DialogTitle className="font-manrope font-extrabold text-brandblue text-xl">
Change default plan?
</DialogTitle>
</ModalHeader>
<p className="text-sm text-gray-500">
<span className="font-semibold text-brandblue">{planName ?? "This plan"}</span> will become the default plan for this property. The current default plan will be moved to the secondary list.
<p className="text-sm text-gray-500 leading-relaxed">
<span className="font-semibold text-brandblue">{planName ?? "This plan"}</span> will
become the recommended strategy shown at the top of the page. You can change it again
at any time.
</p>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setSetDefaultOpen(false)}
disabled={setDefaultMutation.isPending}
>
<DialogFooter className="gap-2 pt-2">
<Button variant="outline" onClick={() => setSetDefaultOpen(false)}>
Cancel
</Button>
<Button
onClick={() => setDefaultMutation.mutate()}
disabled={setDefaultMutation.isPending}
className="bg-brandblue hover:bg-hoverblue text-white"
onClick={handleSetDefault}
>
{setDefaultMutation.isPending ? "Updating…" : "Set as Default"}
Yes, set as default
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete preview modal */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<ModalHeader>
<DialogTitle className="text-red-600">Delete plan</DialogTitle>
@ -326,11 +232,7 @@ export default function PlanCard({
)}
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteMutation.isPending}
>
<Button variant="outline" onClick={() => setOpen(false)} disabled={deleteMutation.isPending}>
Cancel
</Button>
<Button

View file

@ -1,17 +1,12 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter, usePathname } from "next/navigation";
import {
ArrowRightIcon,
BanknotesIcon,
ArrowTrendingUpIcon,
CloudIcon,
WrenchScrewdriverIcon,
EllipsisVerticalIcon,
} from "@heroicons/react/24/outline";
import { useQuery } from "@tanstack/react-query";
import {
DropdownMenu,
DropdownMenuContent,
@ -34,11 +29,13 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import { Button } from "@/app/shadcn_components/ui/button";
import { getEpcColorClass, formatNumber } from "@/app/utils";
import { formatNumber } from "@/app/utils";
type DeletionPreviewRow = { table: string; count: number };
async function fetchPlanDeletionPreview(planId: string): Promise<DeletionPreviewRow[]> {
async function fetchPlanDeletionPreview(
planId: string,
): Promise<DeletionPreviewRow[]> {
const res = await fetch(`/api/plan/${planId}/delete/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -58,27 +55,36 @@ async function confirmPlanDeletion(planId: string): Promise<void> {
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";
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 EpcBadge({ rating, label }: { rating: string; label: string }) {
const colorClass = getEpcColorClass(rating);
return (
<div className="flex flex-col items-center gap-1.5">
<span className="text-[10px] uppercase tracking-widest text-gray-400 font-bold">{label}</span>
<span className={`${colorClass} text-white text-2xl font-black w-12 h-12 rounded-xl flex items-center justify-center leading-none shadow-sm`}>
{rating}
</span>
</div>
);
function epcToPosition(rating: string): number {
const map: Record<string, number> = {
G: 0,
F: 1 / 6,
E: 2 / 6,
D: 3 / 6,
C: 4 / 6,
B: 5 / 6,
A: 1,
};
return map[rating?.toUpperCase()] ?? 0;
}
export default function PlanHeroCard({
@ -90,6 +96,8 @@ export default function PlanHeroCard({
totalSapPoints,
co2Savings,
energyBillSavings,
valuationIncreaseLowerBound,
valuationIncreaseUpperBound,
createdAt,
}: {
planId: string;
@ -100,13 +108,16 @@ export default function PlanHeroCard({
totalSapPoints: number;
co2Savings: number | null;
energyBillSavings: number | null;
valuationIncreaseLowerBound: number | null;
valuationIncreaseUpperBound: number | null;
createdAt: Date;
}) {
const [deleteOpen, setDeleteOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();
const sapImprovement = Math.round((totalSapPoints + Number.EPSILON) * 100) / 100;
const sapImprovement =
Math.round((totalSapPoints + Number.EPSILON) * 100) / 100;
const createdDate = new Date(createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
@ -115,15 +126,22 @@ export default function PlanHeroCard({
const currentHex = getEpcHex(currentEpcRating);
const expectedHex = getEpcHex(expectedEpcRating);
const currentPos = epcToPosition(currentEpcRating);
const expectedPos = epcToPosition(expectedEpcRating);
/* Delete preview query */
const { data: preview = [], isLoading: previewLoading, isError: previewError } = useQuery({
const carsEquivalent =
co2Savings != null ? Math.round(co2Savings / 1.47) : null;
const {
data: preview = [],
isLoading: previewLoading,
isError: previewError,
} = useQuery({
queryKey: ["planDeletionPreview", planId],
queryFn: () => fetchPlanDeletionPreview(planId),
enabled: deleteOpen,
});
/* Delete mutation */
const deleteMutation = useMutation({
mutationFn: () => confirmPlanDeletion(planId),
onSuccess: () => {
@ -134,22 +152,30 @@ export default function PlanHeroCard({
return (
<>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* ── Featured hero card ─────────────────────────────── */}
<div className="lg:col-span-8 bg-white rounded-2xl shadow-sm border border-gray-100 p-8 flex flex-col md:flex-row gap-8">
{/* Left: content */}
<div className="lg:col-span-8 p-8 md:p-10 flex flex-col gap-8">
<div className="flex-1 flex flex-col gap-7">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<span className="inline-flex items-center gap-1.5 bg-brandblue/10 text-brandblue text-[10px] font-bold uppercase tracking-widest px-2.5 py-1 rounded-full mb-3">
Default Plan
<span className="inline-flex items-center gap-1.5 bg-brandblue text-brandgold px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider mb-3">
<svg
className="w-2.5 h-2.5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
Recommended Strategy
</span>
<h2 className="font-manrope font-extrabold text-3xl text-brandblue tracking-tight">
{planName ?? "Unnamed Plan"}
</h2>
<p className="text-sm text-gray-400 font-medium mt-1">Created {createdDate}</p>
<p className="text-sm text-gray-400 font-medium mt-1">
Created: {createdDate}
</p>
</div>
<DropdownMenu>
@ -173,79 +199,62 @@ export default function PlanHeroCard({
</DropdownMenu>
</div>
{/* EPC progression */}
{/* EPC progress */}
<div className="space-y-4">
<div className="flex justify-between text-[10px] font-bold uppercase tracking-widest text-gray-400">
<span>Current Rating: {currentEpcRating}</span>
<span>Target Rating: {expectedEpcRating}</span>
</div>
<div className="relative h-3 w-full bg-gray-100 rounded-full overflow-hidden">
{/* Bar + pin */}
<div className="relative py-2.5">
<div className="relative h-3 w-full bg-gray-100 rounded-full">
<div
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${expectedPos * 100}%`,
background: `linear-gradient(to right, ${currentHex}, ${expectedHex})`,
}}
/>
</div>
{/* Current position pin */}
<div
className="absolute inset-y-0 left-0 rounded-full"
className="absolute top-1/2 w-5 h-5 rounded-full border-4 bg-white shadow-md z-10"
style={{
width: "100%",
background: `linear-gradient(to right, ${currentHex}, ${expectedHex})`,
left: `${currentPos * 100}%`,
transform: "translateX(-50%) translateY(-50%)",
borderColor: currentHex,
}}
/>
</div>
<div className="flex items-center justify-between pt-1">
<div className="flex items-center gap-3">
<EpcBadge rating={currentEpcRating} label="Current" />
<ArrowRightIcon className="w-5 h-5 text-gray-300 shrink-0" />
<EpcBadge rating={expectedEpcRating} label="Expected" />
{/* SAP gain + cost */}
<div className="flex items-end justify-between pt-1">
<div>
<span className="font-manrope font-black text-2xl text-brandblue tabular-nums">
+{sapImprovement}
</span>
<span className="text-sm font-medium text-gray-400 ml-1.5">
SAP Gain
</span>
</div>
<div className="text-right">
<p className="text-xs text-gray-400 font-medium mb-0.5">
Estimated Investment
</p>
<span className="font-manrope font-black text-2xl text-brandblue tabular-nums">
£{formatNumber(totalEstimatedCost)}
</span>
</div>
</div>
</div>
{/* Stat chips */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-4 py-3">
<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-bold">SAP Gain</span>
</div>
<span className="font-manrope text-lg font-black text-brandblue tabular-nums">
+{sapImprovement}
</span>
</div>
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-4 py-3">
<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-bold">Est. Cost</span>
</div>
<span className="font-manrope text-lg font-black text-brandblue tabular-nums">
£{formatNumber(totalEstimatedCost)}
</span>
</div>
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-4 py-3">
<div className="flex items-center gap-1.5">
<CloudIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-bold">CO Saved</span>
</div>
<span className="font-manrope text-lg font-black text-brandblue tabular-nums">
{co2Savings != null ? `${co2Savings.toFixed(1)} t` : "—"}
</span>
</div>
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-4 py-3">
<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-bold">Bill Saving</span>
</div>
<span className="font-manrope text-lg font-black text-brandblue tabular-nums">
{energyBillSavings != null ? `£${formatNumber(energyBillSavings)}/yr` : "—"}
</span>
</div>
</div>
{/* CTA */}
<div>
{/* CTA button */}
<div className="flex justify-end mt-auto">
<button
type="button"
onClick={() => router.push(`${pathname}/${planId}`)}
className="inline-flex items-center gap-2 bg-brandblue text-white font-manrope font-bold px-8 py-3.5 rounded-xl hover:bg-hoverblue transition-colors"
className="py-2.5 px-6 rounded-xl bg-brandblue text-white font-manrope font-bold text-sm hover:bg-hoverblue transition-colors active:scale-95 flex items-center gap-2"
>
View Plan
<ArrowRightIcon className="w-4 h-4" />
@ -253,23 +262,160 @@ export default function PlanHeroCard({
</div>
</div>
{/* Right: visual panel */}
<div className="lg:col-span-4 bg-gradient-to-br from-brandblue to-hoverblue hidden lg:flex items-center justify-center p-12 relative overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute top-8 right-8 w-40 h-40 rounded-full bg-white" />
<div className="absolute bottom-4 left-4 w-24 h-24 rounded-full bg-white" />
</div>
<div className="relative z-10 flex flex-col items-center gap-4 text-white text-center">
<div className="w-20 h-20 rounded-2xl bg-white/10 border border-white/20 flex items-center justify-center">
<WrenchScrewdriverIcon className="w-10 h-10 text-white/80" />
{/* Right: image */}
<div className="w-full md:w-64 h-56 md:h-auto rounded-xl overflow-hidden flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://lh3.googleusercontent.com/aida-public/AB6AXuD3ciUtcps6C-Cimp8GUpI-SmEjXEAyrwoPfhhVGV2-q_4KV1pYKqP1zGAVF7mN4NYLVsKIRW4qSphWvqwSOTbnEtT_ogwJ_jz1bSFbUG34gG_dVpBLjMGuo2yFlgVWDZDPFFbm2j5HQtRcXQemDnyQ6uHb8oxqhSu8_duOWSUGMcWPZAR_pPUUOoGd9g5hCjf00Amhs6GE61LlTM0U7B0hrKB58570DMDK_5mwabLnEF0W2sFV6ADwcIZpt_ZJuE0dMp3YzvEPjiBz"
alt="Modern architectural house exterior"
className="w-full h-full object-cover"
/>
</div>
</div>
{/* ── Impact card ─────────────────────────────── */}
<div className="lg:col-span-4 bg-gradient-to-br from-brandblue to-[#0a2a4a] text-white rounded-2xl p-8 flex flex-col justify-between shadow-sm border border-white/10">
<div className="space-y-6">
<h3 className="font-manrope font-bold text-xl">Impact</h3>
{/* CO2 */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M12 2a7 7 0 017 7c0 5-7 13-7 13S5 14 5 9a7 7 0 017-7z" />
<circle cx="12" cy="9" r="2.5" />
</svg>
</div>
<div className="flex-1">
<p className="text-xs text-white/60 font-medium">
Annual CO Reduction
</p>
<div className="flex items-center gap-2">
<p className="font-manrope font-bold text-lg">
{co2Savings != null
? `${co2Savings.toFixed(1)} Tonnes`
: "—"}
</p>
{carsEquivalent != null && carsEquivalent > 0 && (
<div className="relative group/co2tooltip">
<button
type="button"
className="w-4 h-4 rounded-full bg-white/25 flex items-center justify-center text-white hover:bg-white/40 transition-colors cursor-help flex-shrink-0"
tabIndex={-1}
aria-label="Carbon equivalent info"
>
<span className="text-[9px] font-black leading-none">
!
</span>
</button>
<div className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-3 w-64 opacity-0 group-hover/co2tooltip:opacity-100 transition-opacity z-30">
<div className="bg-white rounded-2xl shadow-2xl p-4 border border-gray-100">
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-full bg-brandblue/8 flex items-center justify-center flex-shrink-0">
<svg
className="w-3.5 h-3.5 text-brandblue"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M5 17H3a1 1 0 01-1-1v-4l2.5-5h11L18 12v4a1 1 0 01-1 1h-2" />
<circle cx="7.5" cy="17.5" r="1.5" />
<circle cx="16.5" cy="17.5" r="1.5" />
<path d="M5 12h14" />
</svg>
</div>
<p className="text-xs font-bold text-brandblue">
Equivalent Impact
</p>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
Like taking{" "}
<span className="font-bold text-brandblue">
~{carsEquivalent} car
{carsEquivalent !== 1 ? "s" : ""}
</span>{" "}
off the road for a year based on the avg. UK car
emitting 1.47 t CO/yr.
</p>
</div>
{/* Caret */}
<div className="flex justify-center">
<div className="w-3 h-1.5 overflow-hidden">
<div className="w-3 h-3 bg-white border border-gray-100 rotate-45 -translate-y-1.5 shadow-sm" />
</div>
</div>
</div>
</div>
)}
</div>
</div>
<p className="font-manrope font-bold text-lg text-white/90 leading-snug max-w-[180px]">
Curated retrofit strategy
</p>
<p className="text-xs text-white/60 font-medium leading-relaxed max-w-[160px]">
Engineered to maximise energy performance and long-term value.
</p>
</div>
{/* Bill savings */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20" />
</svg>
</div>
<div>
<p className="text-xs text-white/60 font-medium">
Bill Savings (Est.)
</p>
<p className="font-manrope font-bold text-lg">
{energyBillSavings != null
? `£${formatNumber(energyBillSavings)} / yr`
: "—"}
</p>
</div>
</div>
{/* Valuation uplift */}
{(valuationIncreaseLowerBound != null ||
valuationIncreaseUpperBound != null) && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/15 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<path d="M9 22V12h6v10" />
<path d="M12 7v5m0 0l-2-2m2 2l2-2" />
</svg>
</div>
<div>
<p className="text-xs text-white/60 font-medium">
Valuation Uplift (Est.)
</p>
<p className="font-manrope font-bold text-lg">
{valuationIncreaseLowerBound != null &&
valuationIncreaseUpperBound != null
? `${formatNumber(valuationIncreaseLowerBound * 100)}% ${formatNumber(valuationIncreaseUpperBound * 100)}%`
: valuationIncreaseLowerBound != null
? `${formatNumber(valuationIncreaseLowerBound * 100)}%+`
: `Up to ${formatNumber(valuationIncreaseUpperBound! * 100)}%`}
</p>
</div>
</div>
)}
</div>
</div>
</div>
@ -283,7 +429,9 @@ export default function PlanHeroCard({
{previewLoading ? (
<p className="text-sm text-gray-500">Loading deletion preview</p>
) : previewError ? (
<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>
@ -296,8 +444,12 @@ export default function PlanHeroCard({
<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>
@ -305,10 +457,18 @@ export default function PlanHeroCard({
</div>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleteMutation.isPending}>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteMutation.isPending}
>
Cancel
</Button>
<Button variant="destructive" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
<Button
variant="destructive"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Deleting…" : "Delete plan"}
</Button>
</DialogFooter>

View file

@ -20,17 +20,18 @@ export default async function RecommendationPlans(props: {
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
Retrofit Strategy
</p>
<h1 className="font-manrope font-extrabold text-4xl text-brandblue tracking-tight">
Retrofit Plans
</h1>
</header>
<div className="bg-white rounded-2xl border border-dashed border-gray-200 p-16 flex flex-col items-center justify-center text-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-gray-50 border border-gray-200 flex items-center justify-center">
<WrenchScrewdriverIcon className="w-7 h-7 text-gray-400" />
</div>
<div>
<p className="font-manrope font-bold text-brandblue text-lg">No plans yet</p>
<p className="text-sm text-gray-400 mt-1">Retrofit plans will appear here once they have been generated.</p>
<p className="font-manrope font-bold text-brandblue text-lg">
No plans yet
</p>
<p className="text-sm text-gray-400 mt-1">
Retrofit plans will appear here once they have been generated.
</p>
</div>
</div>
</div>
@ -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 (
<div className="max-w-7xl mx-auto py-10 space-y-10">
{/* Page header */}
<header>
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest mb-2">
@ -69,7 +67,7 @@ export default async function RecommendationPlans(props: {
</h1>
</header>
{/* Hero — default plan */}
{/* Hero — default plan + carbon impact card */}
<PlanHeroCard
planId={String(defaultPlan.id)}
planName={defaultPlan.name}
@ -79,12 +77,18 @@ export default async function RecommendationPlans(props: {
totalSapPoints={defaultMetrics.totalSapPoints}
co2Savings={defaultPlan.co2Savings ?? null}
energyBillSavings={defaultPlan.energyBillSavings ?? null}
valuationIncreaseLowerBound={
defaultPlan.valuationIncreaseLowerBound ?? null
}
valuationIncreaseUpperBound={
defaultPlan.valuationIncreaseUpperBound ?? null
}
createdAt={defaultPlan.createdAt}
/>
{/* Secondary plans */}
{otherPlans.length > 0 && (
<section>
{/* Secondary plans grid */}
<section>
{otherPlans.length > 0 && (
<div className="flex items-center gap-3 mb-6">
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
Other Plans
@ -93,27 +97,36 @@ export default async function RecommendationPlans(props: {
{otherPlans.length}
</span>
</div>
)}
<div className="flex gap-5 overflow-x-auto pb-4 -mx-1 px-1">
<div className="relative">
{/* Gradient fade to indicate overflow */}
<div className="pointer-events-none absolute right-0 inset-y-0 w-20 bg-gradient-to-l from-white to-transparent z-10" />
<div className="flex gap-6 overflow-x-auto pb-4 scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{otherPlans.map((plan) => {
const { totalEstimatedCost, totalSapPoints, expectedEpcRating } = getPlanMetrics(plan);
const { totalEstimatedCost, totalSapPoints, expectedEpcRating } =
getPlanMetrics(plan);
return (
<PlanCard
<div
key={String(plan.id)}
expectedEpcRating={expectedEpcRating}
currentEpcRating={propertyMeta.currentEpcRating}
createdAt={plan.createdAt}
totalEstimatedCost={totalEstimatedCost}
totalSapPoints={totalSapPoints}
planName={plan.name}
planId={String(plan.id)}
isDefault={false}
/>
className="min-w-[300px] flex-shrink-0 snap-start"
>
<PlanCard
expectedEpcRating={expectedEpcRating}
currentEpcRating={propertyMeta.currentEpcRating}
createdAt={plan.createdAt}
totalEstimatedCost={totalEstimatedCost}
totalSapPoints={totalSapPoints}
planName={plan.name}
planId={String(plan.id)}
isDefault={false}
/>
</div>
);
})}
</div>
</section>
)}
</div>
</section>
</div>
);
}