mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
This commit is contained in:
parent
c3e657847a
commit
0c0529b234
4 changed files with 571 additions and 67 deletions
39
src/app/api/plan/[id]/set-default/route.ts
Normal file
39
src/app/api/plan/[id]/set-default/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/app/db/db";
|
||||
import { plan } from "@/app/db/schema/recommendations";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await context.params;
|
||||
const planId = Number(id);
|
||||
|
||||
if (Number.isNaN(planId)) {
|
||||
return NextResponse.json({ error: "Invalid plan id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const target = await db.query.plan.findFirst({
|
||||
where: eq(plan.id, BigInt(planId)),
|
||||
columns: { propertyId: true },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: "Plan not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(plan)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(plan.propertyId, target.propertyId));
|
||||
|
||||
await tx
|
||||
.update(plan)
|
||||
.set({ isDefault: true })
|
||||
.where(eq(plan.id, BigInt(planId)));
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
TrashIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
CalendarIcon,
|
||||
BanknotesIcon,
|
||||
ArrowRightIcon,
|
||||
EllipsisVerticalIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
|
||||
|
|
@ -30,6 +30,13 @@ import {
|
|||
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";
|
||||
|
||||
/* ----------------------------------------
|
||||
|
|
@ -70,6 +77,15 @@ async function confirmPlanDeletion(planId: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
----------------------------------------- */
|
||||
|
|
@ -98,6 +114,7 @@ export default function PlanCard({
|
|||
totalSapPoints,
|
||||
planName,
|
||||
planId,
|
||||
isDefault,
|
||||
}: {
|
||||
expectedEpcRating: string;
|
||||
currentEpcRating: string;
|
||||
|
|
@ -106,8 +123,10 @@ export default function PlanCard({
|
|||
totalSapPoints: number;
|
||||
planName: string | null;
|
||||
planId: string;
|
||||
isDefault: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [setDefaultOpen, setSetDefaultOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
|
|
@ -119,14 +138,23 @@ export default function PlanCard({
|
|||
} = useQuery({
|
||||
queryKey: ["planDeletionPreview", planId],
|
||||
queryFn: () => fetchPlanDeletionPreview(planId),
|
||||
enabled: open,
|
||||
enabled: deleteOpen,
|
||||
});
|
||||
|
||||
/* -------- Delete mutation -------- */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => confirmPlanDeletion(planId),
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setDeleteOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
/* -------- Set default mutation -------- */
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: () => setDefaultPlan(planId),
|
||||
onSuccess: () => {
|
||||
setSetDefaultOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
|
@ -141,31 +169,51 @@ export default function PlanCard({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm overflow-hidden hover:shadow-md transition-shadow">
|
||||
<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-6 pt-5 pb-4 border-b border-gray-100">
|
||||
<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-base font-bold text-brandblue">
|
||||
<h3 className="text-sm font-bold text-brandblue leading-snug">
|
||||
{planName ?? "Unnamed Plan"}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-400/40 transition"
|
||||
aria-label="Delete plan"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<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 && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={() => setSetDefaultOpen(true)}
|
||||
>
|
||||
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-6 py-4 flex flex-col gap-5">
|
||||
<div className="px-5 py-4 flex flex-col gap-4">
|
||||
|
||||
{/* EPC progression */}
|
||||
<div className="flex items-center gap-4">
|
||||
<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" />
|
||||
|
|
@ -176,33 +224,33 @@ export default function PlanCard({
|
|||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BanknotesIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Est. Cost</span>
|
||||
<div 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-sm font-bold text-gray-800 tabular-nums">
|
||||
<span className="text-xs font-bold text-gray-800 tabular-nums">
|
||||
£{formatNumber(totalEstimatedCost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowTrendingUpIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">SAP Gain</span>
|
||||
<div 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-sm font-bold text-gray-800 tabular-nums">
|
||||
+{sapImprovement} pts
|
||||
<span className="text-xs font-bold text-gray-800 tabular-nums">
|
||||
+{sapImprovement}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-gray-50 border border-gray-100 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">Created</span>
|
||||
<div 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-xs font-semibold text-gray-700">{createdDate}</span>
|
||||
<span className="text-[10px] font-semibold text-gray-700">{createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -218,8 +266,35 @@ export default function PlanCard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Set default confirmation dialog */}
|
||||
<Dialog open={setDefaultOpen} onOpenChange={setSetDefaultOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<ModalHeader>
|
||||
<DialogTitle>Set as 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>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSetDefaultOpen(false)}
|
||||
disabled={setDefaultMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDefaultMutation.mutate()}
|
||||
disabled={setDefaultMutation.isPending}
|
||||
>
|
||||
{setDefaultMutation.isPending ? "Updating…" : "Set as Default"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete preview modal */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<ModalHeader>
|
||||
<DialogTitle className="text-red-600">Delete plan</DialogTitle>
|
||||
|
|
@ -253,7 +328,7 @@ export default function PlanCard({
|
|||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -0,0 +1,319 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } 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,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/app/shadcn_components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/app/shadcn_components/ui/table";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { getEpcColorClass, formatNumber } from "@/app/utils";
|
||||
|
||||
type DeletionPreviewRow = { table: string; count: number };
|
||||
|
||||
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");
|
||||
return (await res.json()).preview;
|
||||
}
|
||||
|
||||
async function confirmPlanDeletion(planId: string): Promise<void> {
|
||||
const res = await fetch(`/api/plan/${planId}/delete/confirm`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete plan");
|
||||
}
|
||||
|
||||
function getEpcHex(letter: string | null | undefined): string {
|
||||
switch (letter?.toUpperCase()) {
|
||||
case "A": return "#117d58";
|
||||
case "B": return "#2da55c";
|
||||
case "C": return "#8dbd40";
|
||||
case "D": return "#f7cd14";
|
||||
case "E": return "#f3a96a";
|
||||
case "F": return "#ef8026";
|
||||
case "G": return "#e41e3b";
|
||||
default: return "#9ca3af";
|
||||
}
|
||||
}
|
||||
|
||||
function 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanHeroCard({
|
||||
planId,
|
||||
planName,
|
||||
currentEpcRating,
|
||||
expectedEpcRating,
|
||||
totalEstimatedCost,
|
||||
totalSapPoints,
|
||||
co2Savings,
|
||||
energyBillSavings,
|
||||
createdAt,
|
||||
}: {
|
||||
planId: string;
|
||||
planName: string | null;
|
||||
currentEpcRating: string;
|
||||
expectedEpcRating: string;
|
||||
totalEstimatedCost: number;
|
||||
totalSapPoints: number;
|
||||
co2Savings: number | null;
|
||||
energyBillSavings: 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 createdDate = new Date(createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const currentHex = getEpcHex(currentEpcRating);
|
||||
const expectedHex = getEpcHex(expectedEpcRating);
|
||||
|
||||
/* Delete preview query */
|
||||
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: () => {
|
||||
setDeleteOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
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">
|
||||
|
||||
{/* Left: content */}
|
||||
<div className="lg:col-span-8 p-8 md:p-10 flex flex-col gap-8">
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
</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-5 w-5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 focus:text-red-600 focus:bg-red-50 cursor-pointer"
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete Plan
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* EPC progression */}
|
||||
<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">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
background: `linear-gradient(to right, ${currentHex}, ${expectedHex})`,
|
||||
}}
|
||||
/>
|
||||
</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" />
|
||||
</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>
|
||||
<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"
|
||||
>
|
||||
View Plan
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</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" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete modal */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-600">Delete plan</DialogTitle>
|
||||
</DialogHeader>
|
||||
{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>
|
||||
) : (
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead className="text-right">Rows deleted</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleteMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending}>
|
||||
{deleteMutation.isPending ? "Deleting…" : "Delete plan"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,48 +1,119 @@
|
|||
import { getPlans, getPropertyMeta } from "../utils";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import PlanCard from "./PlanCard";
|
||||
import PlanHeroCard from "./PlanHeroCard";
|
||||
import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export default async function RecommendationPlans(props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const plans = await getPlans(params.propertyId);
|
||||
const [propertyMeta, plans] = await Promise.all([
|
||||
getPropertyMeta(params.propertyId),
|
||||
getPlans(params.propertyId),
|
||||
]);
|
||||
|
||||
if (plans.length === 0) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-10 space-y-8">
|
||||
<header>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPlanMetrics(plan: (typeof plans)[number]) {
|
||||
const totalEstimatedCost = plan.costOfWorks ?? 0;
|
||||
const totalSapPoints =
|
||||
(plan.postSapPoints ?? propertyMeta.currentSapPoints) -
|
||||
propertyMeta.currentSapPoints;
|
||||
const expectedSapPoints = Math.min(
|
||||
propertyMeta.currentSapPoints + totalSapPoints,
|
||||
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="leading-loose tracking-wider">
|
||||
<div className="flex py-8 text-lg">Retrofit Plans</div>
|
||||
<div className="max-w-7xl mx-auto py-10 space-y-10">
|
||||
|
||||
<div>
|
||||
{plans.map((plan) => {
|
||||
const totalEstimatedCost = plan.costOfWorks || 0;
|
||||
{/* Page header */}
|
||||
<header>
|
||||
<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>
|
||||
|
||||
const totalSapPoints =
|
||||
(plan.postSapPoints || propertyMeta.currentSapPoints) -
|
||||
propertyMeta.currentSapPoints;
|
||||
{/* Hero — default plan */}
|
||||
<PlanHeroCard
|
||||
planId={String(defaultPlan.id)}
|
||||
planName={defaultPlan.name}
|
||||
currentEpcRating={propertyMeta.currentEpcRating}
|
||||
expectedEpcRating={defaultMetrics.expectedEpcRating}
|
||||
totalEstimatedCost={defaultMetrics.totalEstimatedCost}
|
||||
totalSapPoints={defaultMetrics.totalSapPoints}
|
||||
co2Savings={defaultPlan.co2Savings ?? null}
|
||||
energyBillSavings={defaultPlan.energyBillSavings ?? null}
|
||||
createdAt={defaultPlan.createdAt}
|
||||
/>
|
||||
|
||||
const expectedSapPoints = Math.min(
|
||||
propertyMeta.currentSapPoints + totalSapPoints,
|
||||
100
|
||||
);
|
||||
{/* Secondary plans */}
|
||||
{otherPlans.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<p className="font-manrope text-xs font-bold text-brandmidblue uppercase tracking-widest">
|
||||
Other Plans
|
||||
</p>
|
||||
<span className="bg-gray-100 text-gray-500 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
{otherPlans.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
const expectedEpcRating = sapToEpc(expectedSapPoints);
|
||||
|
||||
return (
|
||||
<div key={plan.id} className="mb-4">
|
||||
<PlanCard
|
||||
expectedEpcRating={expectedEpcRating}
|
||||
currentEpcRating={propertyMeta.currentEpcRating}
|
||||
createdAt={plan.createdAt}
|
||||
totalEstimatedCost={totalEstimatedCost}
|
||||
totalSapPoints={totalSapPoints}
|
||||
planName={plan.name}
|
||||
planId={String(plan.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-5 overflow-x-auto pb-4 -mx-1 px-1">
|
||||
{otherPlans.map((plan) => {
|
||||
const { totalEstimatedCost, totalSapPoints, expectedEpcRating } = getPlanMetrics(plan);
|
||||
return (
|
||||
<PlanCard
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue