mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
refactored decent homes screen
Some checks are pending
Next.js Build Check / build (push) Waiting to run
Some checks are pending
Next.js Build Check / build (push) Waiting to run
This commit is contained in:
parent
0c0529b234
commit
e048850196
4 changed files with 604 additions and 512 deletions
|
|
@ -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",
|
||||
},
|
||||
"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: <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",
|
||||
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: "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: <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", "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 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue