From 66a43a19b8f32aa8a97d3d192640628c2278a202 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Sep 2025 21:37:50 +0000 Subject: [PATCH] Added the summary dashboard --- .../decent-homes/DecentHomesDashboard.tsx | 445 +++++++++++------- .../[slug]/(portfolio)/decent-homes/page.tsx | 155 +++--- 2 files changed, 339 insertions(+), 261 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx b/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx index eae2e1cc..8355576c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx @@ -9,16 +9,20 @@ import { DrawerHeader, DrawerTitle, } from "@/app/shadcn_components/ui/drawer"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +// Types interface PropertySummary { id: bigint; uprn: bigint; address: string; postcode: string; - has_condition_data: boolean; - passes_decent_homes: boolean; - category1: Record; - category2: Record; + decent_homes: "pass" | "fail" | "no_data"; + criterion_a: "pass" | "fail" | "no_data"; + criterion_b: "pass" | "fail" | "no_data"; + criterion_c: "pass" | "fail" | "no_data"; + criterion_d: "pass" | "fail" | "no_data"; + replacements?: { sub_variable: string; expiry_date: string | null }[]; } interface Props { @@ -26,49 +30,170 @@ interface Props { portfolioId: string; } -type ReasonKey = - | "damp_passes" - | "structure_passes" - | "heating_passes" - | "sap_passes" - | "thermal_comfort_passes" - | "defects_pass" - | "windows_pass" - | "kitchen_pass" - | "bathroom_pass" - | "space_pass"; +// --- Urgency Badge for notifications --- +function UrgencyBadge({ label }: { label: string }) { + const colorMap: Record = { + Overdue: "bg-red-600", + "0–6 months": "bg-orange-500", + "6–12 months": "bg-yellow-500", + ">12 months": "bg-green-600", + }; + return ( + + {label} + + ); +} -const reasonLabelMap: Record = { - damp_passes: "Damp", - structure_passes: "Structural Issues", - heating_passes: "Heating", - sap_passes: "SAP Rating", - thermal_comfort_passes: "Thermal Comfort", - defects_pass: "General Disrepair", - windows_pass: "Windows", - kitchen_pass: "Kitchen", - bathroom_pass: "Bathroom", - space_pass: "Space Standard", -}; +function NotificationsPanel({ + data, + portfolioId, +}: { + data: PropertySummary[]; + portfolioId: string; +}) { + const today = new Date(); + const groups: Record< + string, + { + property: PropertySummary; + sub_variable: string; + expiry: Date; + remaining: string; + }[] + > = { + Overdue: [], + "0–6 months": [], + "6–12 months": [], + ">12 months": [], + }; + data.forEach((p) => { + p.replacements?.forEach((r) => { + if (!r.expiry_date) return; + const expiry = new Date(r.expiry_date); + const diffMs = expiry.getTime() - today.getTime(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + + const years = Math.floor(Math.abs(diffMonths) / 12); + const months = Math.floor(Math.abs(diffMonths) % 12); + + let remaining = ""; + if (diffMs < 0) { + remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`; + } else { + remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`; + } + + if (diffMs < 0) { + groups.Overdue.push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } else if (diffMonths <= 6) { + groups["0–6 months"].push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } else if (diffMonths <= 12) { + groups["6–12 months"].push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } else { + groups[">12 months"].push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } + }); + }); + + const groupOrder: (keyof typeof groups)[] = [ + "Overdue", + "0–6 months", + "6–12 months", + ">12 months", + ]; + + return ( +
+

+ Upcoming Replacements +

+ + + {groupOrder.map((urgency) => + groups[urgency].length > 0 ? ( +
+
+ + + {groups[urgency].length} item + {groups[urgency].length > 1 ? "s" : ""} + +
+
+ {groups[urgency].map((item, idx) => ( +
+ window.open( + `/portfolio/${portfolioId}/building-passport/${item.property.id}/decent-homes`, + "_blank" + ) + } + > +
+
+ {item.sub_variable} +
+
+ {item.property.address} +
+
+
+ {item.remaining} + + Expiry Date: {item.expiry.toLocaleDateString("en-GB")} + +
+
+ ))} +
+
+ ) : null + )} +
+
+
+ ); +} + +// --- Dashboard --- const DecentHomesDashboard: React.FC = ({ data, portfolioId }) => { const [open, setOpen] = useState(false); const [selectedGroup, setSelectedGroup] = useState([]); const [selectedTitle, setSelectedTitle] = useState(""); const total = data.length; - const passed = data.filter((p) => p.passes_decent_homes); - const failedC1 = data.filter( - (p) => - p.has_condition_data && - Object.values(p.category1).some((v) => v === false) - ); - const failedC2 = data.filter( - (p) => - p.has_condition_data && - Object.values(p.category2).some((v) => v === false) - ); - const missing = data.filter((p) => !p.has_condition_data); + const passes = data.filter((p) => p.decent_homes === "pass"); + const noData = data.filter((p) => p.decent_homes === "no_data"); + const critAFails = data.filter((p) => p.criterion_a === "fail"); + const critBFails = data.filter((p) => p.criterion_b === "fail"); + const critCFails = data.filter((p) => p.criterion_c === "fail"); + const critDFails = data.filter((p) => p.criterion_d === "fail"); + + const overallPassRate = ((passes.length / total) * 100).toFixed(1); const openDrawer = (group: PropertySummary[], title: string) => { setSelectedGroup(group); @@ -76,149 +201,158 @@ const DecentHomesDashboard: React.FC = ({ data, portfolioId }) => { setOpen(true); }; - const failedReasons = new Map(); - - data.forEach((p) => { - if (!p.has_condition_data || p.passes_decent_homes) return; - - const reasons = Object.entries({ ...p.category1, ...p.category2 }) - .filter(([_, v]) => v === false) - .map(([key]) => key); - - reasons.forEach((r) => { - failedReasons.set(r, (failedReasons.get(r) ?? 0) + 1); - }); - }); - - const sortedReasons = Array.from(failedReasons.entries()).sort( - (a, b) => b[1] - a[1] - ); - const top3Reasons = sortedReasons.slice(0, 3); - const dataCompleteness = ( - (data.filter((p) => p.has_condition_data).length / total) * - 100 - ).toFixed(1); - return (
-

Decent Homes Summary

-
-
-
- -
- Overall Pass Rate -
-
- {((passed.length / total) * 100).toFixed(1)}% -
-
- - {passed.length} - {" "} - out of{" "} - {total}{" "} - homes meet the Decent Homes Standard -
+

+ Decent Homes Portfolio Summary +

-

- Data completeness: {dataCompleteness}% -

-
+ {/* 8 compact cards in a 2x4 grid */} +
+ {/* Overall Pass Rate */} +
+
+
+ +
+ Overall Pass Rate +
+
+ {overallPassRate}% +
+
+ + {passes.length} + {" "} + out of{" "} + {total}{" "} + properties +
+
+
-
-
+ {/* Criterion A */} - openDrawer(failedC1, "Properties with Category 1 Risks") - } - className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10" + onClick={() => openDrawer(critAFails, "Criterion A Fails")} + className="cursor-pointer transition hover:bg-hoverblue hover:text-white" > - -
- Category 1 Risks + +
+ Criterion A Fails
-
- {failedC1.length} +
+ {critAFails.length} +
+
+ properties affected
+ {/* Criterion B */} - openDrawer(failedC2, "Properties with Category 2 Risks") - } - className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10" + onClick={() => openDrawer(critBFails, "Criterion B Fails")} + className="cursor-pointer transition hover:bg-hoverblue hover:text-white" > - -
- Category 2 Risks + +
+ Criterion B Fails
-
- {failedC2.length} +
+ {critBFails.length} +
+
+ properties affected
+ {/* Criterion C */} - openDrawer(missing, "Properties Missing Condition Data") - } - className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10" + onClick={() => openDrawer(critCFails, "Criterion C Fails")} + className="cursor-pointer transition hover:bg-hoverblue hover:text-white" > - -
- No Condition Data + +
+ Criterion C Fails
-
- {missing.length} +
+ {critCFails.length} +
+
+ properties affected
+ {/* Criterion D */} openDrawer(passed, "Properties Passing Decent Homes")} - className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out" + onClick={() => openDrawer(critDFails, "Criterion D Fails")} + className="cursor-pointer transition hover:bg-hoverblue hover:text-white" > - -
- Passes Decent Homes + +
+ Criterion D Fails
-
- {passed.length} +
+ {critDFails.length}
+
+ properties affected +
+ + + + {/* Survey information missing */} + openDrawer(noData, "Survey information missing")} + className="cursor-pointer transition hover:bg-hoverblue hover:text-white" + > + +
+ Survey information missing +
+
+ {noData.length} +
+
+ properties affected +
+
+
+ + {/* Passes */} + openDrawer(passes, "Passes Decent Homes")} + className="cursor-pointer transition hover:bg-hoverblue hover:text-white" + > + +
Passes
+
+ {passes.length} +
+
properties
+
+
+ + {/* Total Properties */} + + +
+ Total Properties +
+
{total}
+
portfolio size
-
-

- Top Reasons for Failing -

-
- {top3Reasons.map(([reason, count]) => ( -
-
-
🚫
-

- {reasonLabelMap[reason as ReasonKey] ?? - reason.replace(/_/g, " ")} -

-

- Failing in {count}{" "} - properties -

-
-
- ))} -
-
+ {/* Notifications */} + + {/* Drawer */} @@ -233,30 +367,13 @@ const DecentHomesDashboard: React.FC = ({ data, portfolioId }) => {
UPRN: {String(p.uprn)}
- {p.has_condition_data ? ( - <> - {Object.entries({ ...p.category1, ...p.category2 }) - .filter(([_, value]) => value === false) - .map(([key]) => ( -
- Issue:{" "} - {reasonLabelMap[key as ReasonKey] ?? - key.replace(/_/g, " ")} -
- ))} - - ) : ( -
- No condition data available -
- )}