Added the summary dashboard

This commit is contained in:
Khalim Conn-Kowlessar 2025-09-23 21:37:50 +00:00
parent 65ce0ecb2a
commit 66a43a19b8
2 changed files with 339 additions and 261 deletions

View file

@ -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<string, boolean>;
category2: Record<string, boolean>;
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<string, string> = {
Overdue: "bg-red-600",
"06 months": "bg-orange-500",
"612 months": "bg-yellow-500",
">12 months": "bg-green-600",
};
return (
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
{label}
</Badge>
);
}
const reasonLabelMap: Record<ReasonKey, string> = {
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: [],
"06 months": [],
"612 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["06 months"].push({
property: p,
sub_variable: r.sub_variable,
expiry,
remaining,
});
} else if (diffMonths <= 12) {
groups["612 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",
"06 months",
"612 months",
">12 months",
];
return (
<div className="mt-10">
<h2 className="text-lg font-semibold mb-4 text-brandbrown">
Upcoming Replacements
</h2>
<Card className="max-h-[600px] overflow-y-auto">
<CardContent className="p-4 space-y-2">
{groupOrder.map((urgency) =>
groups[urgency].length > 0 ? (
<div key={urgency}>
<div className="flex items-center space-x-2 mb-3">
<UrgencyBadge label={urgency} />
<span className="text-gray-700 font-medium">
{groups[urgency].length} item
{groups[urgency].length > 1 ? "s" : ""}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
{groups[urgency].map((item, idx) => (
<div
key={idx}
className="p-4 bg-gray-50 rounded-md border hover:bg-gray-100 transition cursor-pointer flex flex-col justify-between"
onClick={() =>
window.open(
`/portfolio/${portfolioId}/building-passport/${item.property.id}/decent-homes`,
"_blank"
)
}
>
<div>
<div className="font-medium text-gray-800">
{item.sub_variable}
</div>
<div className="text-sm text-gray-500">
{item.property.address}
</div>
</div>
<div className="mt-2 flex justify-between text-sm text-gray-600">
<span>{item.remaining}</span>
<span>
Expiry Date: {item.expiry.toLocaleDateString("en-GB")}
</span>
</div>
</div>
))}
</div>
</div>
) : null
)}
</CardContent>
</Card>
</div>
);
}
// --- Dashboard ---
const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
const [open, setOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<PropertySummary[]>([]);
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<Props> = ({ data, portfolioId }) => {
setOpen(true);
};
const failedReasons = new Map<string, number>();
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 (
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-semibold mb-6">Decent Homes Summary</h1>
<div className="sm:col-span-2 rounded-xl bg-brandblue p-[3px] mb-6">
<div className="rounded-lg bg-brandbrown p-[3px]">
<div className="rounded-md bg-brandblue">
<CardContent className="relative p-8 flex flex-col items-center justify-center text-center space-y-3">
<div className="text-sm uppercase tracking-wider font-semibold text-brandbrown">
Overall Pass Rate
</div>
<div className="text-6xl font-extrabold text-white drop-shadow">
{((passed.length / total) * 100).toFixed(1)}%
</div>
<div className="text-md font-medium text-white/80">
<span className="text-brandbrown font-semibold">
{passed.length}
</span>{" "}
out of{" "}
<span className="text-brandbrown font-semibold">{total}</span>{" "}
homes meet the Decent Homes Standard
</div>
<h1 className="text-2xl font-semibold mb-6">
Decent Homes Portfolio Summary
</h1>
<p className="absolute bottom-4 right-4 text-white/50 italic">
Data completeness: {dataCompleteness}%
</p>
</CardContent>
{/* 8 compact cards in a 2x4 grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Overall Pass Rate */}
<div className="rounded-xl bg-brandblue p-[3px]">
<div className="rounded-lg bg-brandbrown p-[3px]">
<div className="rounded-md bg-brandblue h-full">
<CardContent className="p-4 text-center flex flex-col items-center justify-center space-y-2 h-full">
<div className="text-sm uppercase tracking-wider font-semibold text-brandbrown">
Overall Pass Rate
</div>
<div className="text-3xl font-extrabold text-white drop-shadow">
{overallPassRate}%
</div>
<div className="text-sm font-medium text-white/80">
<span className="text-brandbrown font-semibold">
{passes.length}
</span>{" "}
out of{" "}
<span className="text-brandbrown font-semibold">{total}</span>{" "}
properties
</div>
</CardContent>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Criterion A */}
<Card
onClick={() =>
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"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
Category 1 Risks
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion A Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{failedC1.length}
<div className="text-3xl font-bold text-brandbrown">
{critAFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Criterion B */}
<Card
onClick={() =>
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"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
Category 2 Risks
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion B Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{failedC2.length}
<div className="text-3xl font-bold text-brandbrown">
{critBFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Criterion C */}
<Card
onClick={() =>
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"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
No Condition Data
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion C Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{missing.length}
<div className="text-3xl font-bold text-brandbrown">
{critCFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Criterion D */}
<Card
onClick={() => 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"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
Passes Decent Homes
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion D Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{passed.length}
<div className="text-3xl font-bold text-brandbrown">
{critDFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Survey information missing */}
<Card
onClick={() => openDrawer(noData, "Survey information missing")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Survey information missing
</div>
<div className="text-3xl font-bold text-brandbrown">
{noData.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Passes */}
<Card
onClick={() => openDrawer(passes, "Passes Decent Homes")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">Passes</div>
<div className="text-3xl font-bold text-brandbrown">
{passes.length}
</div>
<div className="text-sm text-brandbrown/80">properties</div>
</CardContent>
</Card>
{/* Total Properties */}
<Card className="transition">
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Total Properties
</div>
<div className="text-3xl font-bold text-brandbrown">{total}</div>
<div className="text-sm text-brandbrown/80">portfolio size</div>
</CardContent>
</Card>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4 text-brandbrown">
Top Reasons for Failing
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{top3Reasons.map(([reason, count]) => (
<div
key={reason}
className="bg-brandblue/10 border border-brandblue/20 rounded-lg p-6 shadow-sm flex flex-col justify-between"
>
<div>
<div className="text-red-500 text-2xl mb-2">🚫</div>
<h3 className="text-md font-semibold text-brandbrown mb-1">
{reasonLabelMap[reason as ReasonKey] ??
reason.replace(/_/g, " ")}
</h3>
<p className="text-sm text-muted-foreground">
Failing in <span className="font-medium">{count}</span>{" "}
properties
</p>
</div>
</div>
))}
</div>
</div>
{/* Notifications */}
<NotificationsPanel data={data} portfolioId={portfolioId} />
{/* Drawer */}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="p-4 w-full max-w-md ml-auto border-l h-full overflow-y-auto">
<DrawerHeader>
@ -233,30 +367,13 @@ const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
<div className="text-sm text-muted-foreground mb-2">
UPRN: {String(p.uprn)}
</div>
{p.has_condition_data ? (
<>
{Object.entries({ ...p.category1, ...p.category2 })
.filter(([_, value]) => value === false)
.map(([key]) => (
<div key={key} className="text-red-500 text-sm">
Issue:{" "}
{reasonLabelMap[key as ReasonKey] ??
key.replace(/_/g, " ")}
</div>
))}
</>
) : (
<div className="text-orange-500 text-sm">
No condition data available
</div>
)}
<Button
variant="outline"
size="sm"
className="mt-3 bg-brandblue text-white hover:bg-hoverblue hover:text-gray-100"
onClick={() =>
window.open(
`/portfolio/${portfolioId}/building-passport/${p.id}/assessment`,
`/portfolio/${portfolioId}/building-passport/${p.id}/decent-homes`,
"_blank"
)
}

View file

@ -43,21 +43,32 @@ async function getPropertiesWithUprn(
async function getDocumentsForUprns(
uprns: (string | number)[]
): Promise<Map<string, typeof uploadedFiles.$inferSelect>> {
): Promise<Map<string, Map<string, typeof uploadedFiles.$inferSelect>>> {
const rows = await surveyDB.query.uploadedFiles.findMany({
where: and(
inArray(uploadedFiles.uprn, uprns.map(String)),
eq(uploadedFiles.docType, "ECO_CONDITION_REPORT")
inArray(uploadedFiles.docType, [
"DECENT_HOMES_SUMMARY",
"DECENT_HOMES_PROPERTY_META",
])
),
orderBy: (uploadedFiles, { desc }) => [
desc(uploadedFiles.s3JsonUploadTimestamp),
],
});
const latestByUprn = new Map<string, (typeof rows)[number]>();
// Map<UPRN, Map<docType, row>>
const latestByUprn = new Map<string, Map<string, (typeof rows)[number]>>();
for (const row of rows) {
if (!latestByUprn.has(row.uprn)) {
latestByUprn.set(row.uprn, row);
latestByUprn.set(row.uprn, new Map());
}
const docMap = latestByUprn.get(row.uprn)!;
// if this docType isnt already set, use it (rows are ordered newest first)
if (!docMap.has(row.docType)) {
docMap.set(row.docType, row);
}
}
@ -80,107 +91,57 @@ export default async function DecentHomesPage({
const summaryResults = await Promise.all(
properties.map(async (property) => {
const result: any = {
const propertyDocs = documentsMap.get(String(property.uprn));
const propertyDecentHomes = propertyDocs?.get("DECENT_HOMES_SUMMARY");
const propertyDecentHomesMeta = propertyDocs?.get(
"DECENT_HOMES_PROPERTY_META"
);
if (
!propertyDecentHomes?.s3JsonUri ||
!propertyDecentHomesMeta?.s3JsonUri
) {
return {
id: property.id,
uprn: property.uprn,
address: property.address,
postcode: property.postcode,
decent_homes: "no_data",
criterion_a: "no_data",
criterion_b: "no_data",
criterion_c: "no_data",
criterion_d: "no_data",
replacements: [],
};
}
const decentHomes = await getEnergyAssessmentFromS3(
propertyDecentHomes.s3JsonUri
);
const decentHomesMeta = await getEnergyAssessmentFromS3(
propertyDecentHomesMeta.s3JsonUri
);
return {
id: property.id,
uprn: property.uprn,
address: property.address,
postcode: property.postcode,
has_condition_data: false,
passes_decent_homes: false,
category1: {},
category2: {},
decent_homes: decentHomes.decent_homes,
criterion_a: decentHomes.criterion_a,
criterion_b: decentHomes.criterion_b,
criterion_c: decentHomes.criterion_c,
criterion_d: decentHomes.criterion_d,
replacements: decentHomesMeta.map((m: any) => ({
sub_variable: m.sub_variable,
expiry_date: m.expiry_date ?? null,
})),
};
const propertyMeta = await getPropertyMeta(property.id);
const conditionReportMeta = documentsMap.get(String(propertyMeta.uprn));
if (!conditionReportMeta || !conditionReportMeta.s3JsonUri) {
return result;
}
result.has_condition_data = true;
const conditionReport = await getEnergyAssessmentFromS3(
conditionReportMeta.s3JsonUri
);
const conditionData = await getConditionReport(String(property.id));
if (!conditionReport || !conditionData) return result;
const allRoomData = getAllRoomData(conditionReport.rooms);
const roomsWithDamp = getRoomsWithDamp(allRoomData);
const roomsWithDefects = getRoomsWithDefects(allRoomData);
const roomsWithBadWindows = getRoomsWithBadWindows(allRoomData);
const windowsOk = areAllWindowsOk(allRoomData);
const elevationsWithIssues = getElevationsWithIssues(conditionReport);
const {
isSufficient: enoughSpace,
occupantsToUse,
numberOfBedrooms,
areaPerPerson,
} = hasSufficientSpace(
conditionReport.occupant_info,
conditionData.totalFloorArea ?? 0,
allRoomData
);
const sapOk = meetsSapThreshold(propertyMeta.currentSapPoints ?? 0);
const efficientHeating = hasEfficientHeatingSystem(
conditionData.heatingRating
);
const insulationOk = isInsulationAdequate(
conditionData.roofRating,
conditionData.wallsRating
);
const thermalComfortOk = efficientHeating && insulationOk;
const kitchenOk =
conditionReport.rooms.kitchen?.room_info
?.overall_condition_of_the_room === "Good";
const bathroomsOk =
Array.isArray(conditionReport.rooms.bathrooms) &&
conditionReport.rooms.bathrooms.length > 0 &&
conditionReport.rooms.bathrooms.every(
(b: any) => b?.room_info?.overall_condition_of_the_room === "Good"
);
const hasDefects = roomsWithDefects.length > 0;
result.category1 = {
damp_passes: roomsWithDamp.length === 0,
structure_passes: elevationsWithIssues.length === 0,
heating_passes: efficientHeating,
sap_passes: sapOk,
thermal_comfort_passes: thermalComfortOk,
};
result.category2 = {
defects_pass: !hasDefects,
windows_pass: windowsOk,
kitchen_pass: kitchenOk,
bathroom_pass: bathroomsOk,
space_pass: enoughSpace,
};
// Overall result
result.passes_decent_homes =
result.category1.damp_passes &&
result.category1.structure_passes &&
result.category1.heating_passes &&
result.category1.sap_passes &&
result.category1.thermal_comfort_passes &&
result.category2.defects_pass &&
result.category2.windows_pass &&
result.category2.kitchen_pass &&
result.category2.bathroom_pass &&
result.category2.space_pass;
return result;
})
);
console.log("summaryResults", summaryResults);
return (
<div className="container mx-auto px-4">
<DecentHomesDashboard data={summaryResults} portfolioId={portfolioId} />