mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
completed summary ui
This commit is contained in:
parent
45f5634f03
commit
1617d69b69
6 changed files with 833 additions and 60 deletions
|
|
@ -0,0 +1,276 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/app/shadcn_components/ui/drawer";
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: PropertySummary[];
|
||||
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";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
|
||||
console.log("Data received:", data);
|
||||
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 openDrawer = (group: PropertySummary[], title: string) => {
|
||||
setSelectedGroup(group);
|
||||
setSelectedTitle(title);
|
||||
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>
|
||||
|
||||
<p className="absolute bottom-4 right-4 text-white/50 italic">
|
||||
Data completeness: {dataCompleteness}%
|
||||
</p>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
Category 1 Risks
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{failedC1.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
Category 2 Risks
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{failedC2.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
No Condition Data
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{missing.length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
Passes Decent Homes
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{passed.length}
|
||||
</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>
|
||||
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent className="p-4 w-full max-w-md ml-auto border-l h-full overflow-y-auto">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{selectedTitle}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<ul className="space-y-4">
|
||||
{selectedGroup.map((p) => (
|
||||
<li key={String(p.id)} className="border rounded p-4">
|
||||
<div className="font-semibold">
|
||||
{p.address} ({p.postcode})
|
||||
</div>
|
||||
<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"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/portfolio/${portfolioId}/building-passport/${p.id}/assessment`,
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
>
|
||||
View Property
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DecentHomesDashboard;
|
||||
189
src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx
Normal file
189
src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { property } from "@/app/db/schema/property";
|
||||
import { inArray, eq, and } from "drizzle-orm";
|
||||
import { surveyDB } from "@/app/db/surveyDB/connection";
|
||||
import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
|
||||
import {
|
||||
getEnergyAssessmentFromS3,
|
||||
getConditionReport,
|
||||
getPropertyMeta,
|
||||
} from "@/app/portfolio/[slug]/building-passport/[propertyId]/utils";
|
||||
import {
|
||||
getAllRoomData,
|
||||
getRoomsWithDamp,
|
||||
getRoomsWithDefects,
|
||||
getRoomsWithBadWindows,
|
||||
areAllWindowsOk,
|
||||
getElevationsWithIssues,
|
||||
hasSufficientSpace,
|
||||
meetsSapThreshold,
|
||||
hasEfficientHeatingSystem,
|
||||
isInsulationAdequate,
|
||||
} from "@/app/portfolio/[slug]/building-passport/[propertyId]/assessment/decent_homes_utils";
|
||||
import DecentHomesDashboard from "./DecentHomesDashboard";
|
||||
|
||||
async function getPropertiesWithUprn(
|
||||
portfolioId: string,
|
||||
limit = 1000,
|
||||
offset = 0
|
||||
): Promise<any[]> {
|
||||
const data = await db.query.property.findMany({
|
||||
limit,
|
||||
offset,
|
||||
columns: {
|
||||
id: true,
|
||||
uprn: true,
|
||||
address: true,
|
||||
postcode: true,
|
||||
},
|
||||
where: eq(property.portfolioId, BigInt(portfolioId)),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getDocumentsForUprns(
|
||||
uprns: (string | number)[]
|
||||
): Promise<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")
|
||||
),
|
||||
orderBy: (uploadedFiles, { desc }) => [
|
||||
desc(uploadedFiles.s3JsonUploadTimestamp),
|
||||
],
|
||||
});
|
||||
|
||||
const latestByUprn = new Map<string, (typeof rows)[number]>();
|
||||
for (const row of rows) {
|
||||
if (!latestByUprn.has(row.uprn)) {
|
||||
latestByUprn.set(row.uprn, row);
|
||||
}
|
||||
}
|
||||
|
||||
return latestByUprn;
|
||||
}
|
||||
|
||||
export default async function DecentHomesPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined | number;
|
||||
}>;
|
||||
}) {
|
||||
const { slug: portfolioId } = await params;
|
||||
const properties = await getPropertiesWithUprn(portfolioId, 1000, 0);
|
||||
const uprns = properties.map((p) => String(p.uprn));
|
||||
const documentsMap = await getDocumentsForUprns(uprns);
|
||||
|
||||
const summaryResults = await Promise.all(
|
||||
properties.map(async (property) => {
|
||||
const result: any = {
|
||||
id: property.id,
|
||||
uprn: property.uprn,
|
||||
address: property.address,
|
||||
postcode: property.postcode,
|
||||
has_condition_data: false,
|
||||
passes_decent_homes: false,
|
||||
category1: {},
|
||||
category2: {},
|
||||
};
|
||||
|
||||
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;
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4">
|
||||
<DecentHomesDashboard data={summaryResults} portfolioId={portfolioId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card";
|
||||
import { CheckCircle, XCircle, HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
|
|
@ -32,6 +24,16 @@ import {
|
|||
meetsSapThreshold,
|
||||
} from "./decent_homes_utils";
|
||||
|
||||
type Rating = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
const RatingMap: Record<Rating, string> = {
|
||||
1: "Very Poor",
|
||||
2: "Poor",
|
||||
3: "Average",
|
||||
4: "Good",
|
||||
5: "Very Good",
|
||||
};
|
||||
|
||||
function ChecklistItem({
|
||||
label,
|
||||
passed,
|
||||
|
|
@ -45,18 +47,19 @@ function ChecklistItem({
|
|||
alert?: boolean;
|
||||
roomsWithIssues?: [string, any][];
|
||||
}) {
|
||||
const icon = passed === true ? (
|
||||
<CheckCircle className="text-brandbrown w-5 h-5 shrink-0" />
|
||||
) : passed === false ? (
|
||||
<XCircle
|
||||
className={clsx(
|
||||
"w-5 h-5 shrink-0",
|
||||
alert ? "text-red-600" : "text-brandblue"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="text-brandblue w-5 h-5 shrink-0" />
|
||||
);
|
||||
const icon =
|
||||
passed === true ? (
|
||||
<CheckCircle className="text-brandbrown w-5 h-5 shrink-0" />
|
||||
) : passed === false ? (
|
||||
<XCircle
|
||||
className={clsx(
|
||||
"w-5 h-5 shrink-0",
|
||||
alert ? "text-red-600" : "text-brandblue"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HelpCircle className="text-brandblue w-5 h-5 shrink-0" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 px-3 py-2 rounded-md space-y-2">
|
||||
|
|
@ -73,7 +76,10 @@ function ChecklistItem({
|
|||
</div>
|
||||
|
||||
{roomsWithIssues.length > 0 && (
|
||||
<Accordion type="multiple" className="ml-6 border-l border-muted pl-4 mt-2">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="ml-6 border-l border-muted pl-4 mt-2"
|
||||
>
|
||||
{roomsWithIssues.map(([roomName, room]: any) => (
|
||||
<AccordionItem key={roomName} value={roomName}>
|
||||
<AccordionTrigger>{formatRoomName(roomName)}</AccordionTrigger>
|
||||
|
|
@ -81,14 +87,17 @@ function ChecklistItem({
|
|||
<div className="text-sm text-muted-foreground space-y-1 px-1">
|
||||
{room.room_info?.overall_condition_of_the_room && (
|
||||
<div>
|
||||
<strong>Condition:</strong> {room.room_info.overall_condition_of_the_room}
|
||||
<strong>Condition:</strong>{" "}
|
||||
{room.room_info.overall_condition_of_the_room}
|
||||
</div>
|
||||
)}
|
||||
{room.room_info?.does_the_room_have_any_defects === "Yes" && (
|
||||
<div className="flex items-start gap-2 text-brandblue">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div><strong>Defect reported</strong></div>
|
||||
<div>
|
||||
<strong>Defect reported</strong>
|
||||
</div>
|
||||
{room.room_info?.description_of_defect && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{room.room_info.description_of_defect}
|
||||
|
|
@ -97,11 +106,16 @@ function ChecklistItem({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{room.room_info?.ventilation_info?.location_of_any_damp_or_mould && (
|
||||
{room.room_info?.ventilation_info
|
||||
?.location_of_any_damp_or_mould && (
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>
|
||||
<strong>Damp/Mould Location:</strong> {room.room_info.ventilation_info.location_of_any_damp_or_mould}
|
||||
<strong>Damp/Mould Location:</strong>{" "}
|
||||
{
|
||||
room.room_info.ventilation_info
|
||||
.location_of_any_damp_or_mould
|
||||
}
|
||||
<span className="ml-2 text-xs text-muted-foreground italic">
|
||||
(Severity: Average)
|
||||
</span>
|
||||
|
|
@ -140,7 +154,7 @@ export default function ConditionReport({
|
|||
conditionReport: {
|
||||
rooms: Record<string, any>;
|
||||
[key: string]: any;
|
||||
},
|
||||
};
|
||||
totalFloorArea: number;
|
||||
currentSapPoints: number;
|
||||
conditionData: PropertyDetailsEpc;
|
||||
|
|
@ -151,22 +165,32 @@ export default function ConditionReport({
|
|||
const roomsWithBadWindows = getRoomsWithBadWindows(allRoomData);
|
||||
const windowsOk = areAllWindowsOk(allRoomData);
|
||||
|
||||
const elevationsWithIssues = getElevationsWithIssues(conditionReport.access_and_elevations);
|
||||
const elevationsWithIssues = getElevationsWithIssues(
|
||||
conditionReport.access_and_elevations
|
||||
);
|
||||
|
||||
const {
|
||||
isSufficient: enoughSpace,
|
||||
occupantsToUse,
|
||||
numberOfBedrooms,
|
||||
areaPerPerson,
|
||||
} = hasSufficientSpace(conditionReport.occupant_info, totalFloorArea, allRoomData);
|
||||
} = hasSufficientSpace(conditionReport, totalFloorArea, allRoomData);
|
||||
|
||||
const sapOk = meetsSapThreshold(currentSapPoints);
|
||||
const efficientHeating = hasEfficientHeatingSystem(conditionData.heating || "");
|
||||
const insulationOk = isInsulationAdequate(conditionData.heating || "", conditionData.roofRating, conditionData.wallsRating);
|
||||
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) &&
|
||||
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"
|
||||
|
|
@ -210,26 +234,41 @@ export default function ConditionReport({
|
|||
|
||||
<ChecklistItem
|
||||
label={
|
||||
conditionReport.heating_system?.general_condition?.is_the_heating_system_in_working_order
|
||||
conditionReport.heating_system?.general_condition
|
||||
?.is_the_heating_system_in_working_order
|
||||
? "Heating system operational"
|
||||
: "Heating system not operational"
|
||||
}
|
||||
passed={conditionReport.heating_system?.general_condition?.is_the_heating_system_in_working_order}
|
||||
alert={!conditionReport.heating_system?.general_condition?.is_the_heating_system_in_working_order}
|
||||
passed={
|
||||
conditionReport.heating_system?.general_condition
|
||||
?.is_the_heating_system_in_working_order
|
||||
}
|
||||
alert={
|
||||
!conditionReport.heating_system?.general_condition
|
||||
?.is_the_heating_system_in_working_order
|
||||
}
|
||||
/>
|
||||
|
||||
<ChecklistItem
|
||||
label={sapOk ? "SAP rating meets minimum threshold (≥ 35)" : "SAP rating below recommended minimum"}
|
||||
label={
|
||||
sapOk
|
||||
? "SAP rating meets minimum threshold (≥ 35)"
|
||||
: "SAP rating below recommended minimum"
|
||||
}
|
||||
passed={sapOk}
|
||||
alert={!sapOk}
|
||||
note={`SAP Points: ${currentSapPoints}`}
|
||||
/>
|
||||
|
||||
<ChecklistItem
|
||||
label={thermalComfortOk ? "Thermal comfort conditions met" : "Thermal comfort conditions not met"}
|
||||
label={
|
||||
thermalComfortOk
|
||||
? "Thermal comfort conditions met"
|
||||
: "Thermal comfort conditions not met"
|
||||
}
|
||||
passed={thermalComfortOk}
|
||||
alert={!thermalComfortOk}
|
||||
note={`Heating: ${conditionData.heating}; Roof Rating: ${conditionData.roofRating ?? "N/A"}; Wall Rating: ${conditionData.wallsRating ?? "N/A"}`}
|
||||
note={`Heating: ${conditionData.heating}; Roof Rating: ${RatingMap[conditionData.roofRating as Rating] ?? "N/A"}; Wall Rating: ${RatingMap[conditionData.wallsRating as Rating] ?? "N/A"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -239,7 +278,11 @@ export default function ConditionReport({
|
|||
</h3>
|
||||
|
||||
<ChecklistItem
|
||||
label={hasDefects ? "Room defects present" : "No room defects present"}
|
||||
label={
|
||||
hasDefects
|
||||
? "Room defects present"
|
||||
: "No room defects present"
|
||||
}
|
||||
passed={!hasDefects}
|
||||
alert={hasDefects}
|
||||
roomsWithIssues={roomsWithDefects}
|
||||
|
|
@ -257,13 +300,21 @@ export default function ConditionReport({
|
|||
/>
|
||||
|
||||
<ChecklistItem
|
||||
label={kitchenOk ? "Kitchen in good condition" : "Kitchen not in good condition"}
|
||||
label={
|
||||
kitchenOk
|
||||
? "Kitchen in good condition"
|
||||
: "Kitchen not in good condition"
|
||||
}
|
||||
passed={kitchenOk}
|
||||
alert={!kitchenOk}
|
||||
/>
|
||||
|
||||
<ChecklistItem
|
||||
label={bathroomsOk ? "Bathrooms in good condition" : "Bathrooms not in good condition"}
|
||||
label={
|
||||
bathroomsOk
|
||||
? "Bathrooms in good condition"
|
||||
: "Bathrooms not in good condition"
|
||||
}
|
||||
passed={bathroomsOk}
|
||||
alert={!bathroomsOk}
|
||||
/>
|
||||
|
|
@ -272,7 +323,7 @@ export default function ConditionReport({
|
|||
label="Sufficient space for number of occupants"
|
||||
passed={enoughSpace}
|
||||
alert={!enoughSpace}
|
||||
note={`${occupantsToUse} occupants, ${numberOfBedrooms} bedrooms. ${areaPerPerson}m² per person`}
|
||||
note={`${occupantsToUse} occupants, ${numberOfBedrooms} bedrooms. ${areaPerPerson.toFixed(1)}m² per person`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
// utils/decentHomeChecks.ts
|
||||
|
||||
export function getAllRoomData(rooms: Record<string, any>) {
|
||||
return [
|
||||
...Object.entries(rooms).filter(([_, v]) => v?.room_info),
|
||||
...(rooms.bedrooms || []).map((b: any, i: number) => [
|
||||
"Bedroom " + (i + 1),
|
||||
b,
|
||||
]),
|
||||
...(rooms.bathrooms || []).map((b: any, i: number) => [
|
||||
"Bathroom " + (i + 1),
|
||||
b,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function getRoomsWithDamp(allRoomData: [string, any][]) {
|
||||
return allRoomData.filter(
|
||||
([, room]: any) =>
|
||||
room.room_info?.ventilation_info
|
||||
?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room
|
||||
);
|
||||
}
|
||||
|
||||
export function getRoomsWithDefects(allRoomData: [string, any][]) {
|
||||
return allRoomData.filter(
|
||||
([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes"
|
||||
);
|
||||
}
|
||||
|
||||
export function getRoomsWithBadWindows(allRoomData: [string, any][]) {
|
||||
return allRoomData.filter(([, room]: any) => {
|
||||
const wi = room.room_info?.windows_info;
|
||||
return (
|
||||
wi?.does_the_room_have_any_windows &&
|
||||
wi.condition_of_the_windows !== "Good condition"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function areAllWindowsOk(allRoomData: [string, any][]) {
|
||||
return allRoomData.every(([, room]: any) => {
|
||||
const wi = room.room_info?.windows_info;
|
||||
return wi?.does_the_room_have_any_windows
|
||||
? wi?.condition_of_the_windows === "Good condition"
|
||||
: true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getElevationsWithIssues(conditionReport: any): [string, any][] {
|
||||
const frontElevation =
|
||||
conditionReport.access_and_elevations?.external_elevation_front
|
||||
?.external_elevation;
|
||||
const applyFrontDefaults = (
|
||||
label: string,
|
||||
elevationKey: string
|
||||
): [string, any] => {
|
||||
const section = conditionReport.access_and_elevations?.[elevationKey];
|
||||
if (section?.do_all_answers_for_the_front_elevation_apply_to_this_wall) {
|
||||
return [label, frontElevation];
|
||||
}
|
||||
return [label, section?.external_elevation ?? null];
|
||||
};
|
||||
|
||||
const allElevations: [string, any][] = [
|
||||
["Front Elevation", frontElevation],
|
||||
applyFrontDefaults("Back Elevation", "external_elevation_back"),
|
||||
applyFrontDefaults("Gable One", "external_elevation_gable_one"),
|
||||
applyFrontDefaults("Gable Two", "external_elevation_gable_two"),
|
||||
];
|
||||
|
||||
return allElevations.filter(
|
||||
([_, elevation]) =>
|
||||
elevation &&
|
||||
(elevation.does_any_structural_defect_need_resolving_before_retrofit ===
|
||||
true ||
|
||||
elevation.are_there_any_signs_of_water_penetration_caused_by_failed_rainwater_goods_or_pipework ===
|
||||
true ||
|
||||
elevation.are_there_any_visible_signs_of_movement === true ||
|
||||
elevation.are_there_any_visible_signs_of_cracking_to_the_existing_external_finish ===
|
||||
true)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSufficientSpace(
|
||||
conditionReport: any,
|
||||
totalFloorArea: number,
|
||||
allRoomData: [string, any][]
|
||||
) {
|
||||
const totalOccupants =
|
||||
conditionReport.occupant_info?.total_number_of_occupants ?? 0;
|
||||
const totalAdults = conditionReport.occupant_info?.no_of_adult_occupants ?? 0;
|
||||
const totalChildren =
|
||||
conditionReport.occupant_info?.no_of_child_occupants ?? 0;
|
||||
const occupantsToUse = Math.max(totalAdults + totalChildren, totalOccupants);
|
||||
|
||||
console.log("totalOccupants", totalOccupants);
|
||||
console.log("occupantsToUse", occupantsToUse);
|
||||
|
||||
const numberOfBedrooms = allRoomData.filter(([label]) =>
|
||||
label.includes("Bedroom")
|
||||
).length;
|
||||
|
||||
const getRecommendedOccupants = (bedrooms: number) => {
|
||||
if (bedrooms <= 0) return 0;
|
||||
if (bedrooms === 1) return 2;
|
||||
if (bedrooms === 2) return 4;
|
||||
if (bedrooms === 3) return 6;
|
||||
return 7;
|
||||
};
|
||||
|
||||
const maxOccupants = getRecommendedOccupants(numberOfBedrooms);
|
||||
const areaPerPerson = totalFloorArea / occupantsToUse;
|
||||
const isSufficient = occupantsToUse <= maxOccupants && areaPerPerson >= 20;
|
||||
|
||||
return { isSufficient, occupantsToUse, numberOfBedrooms, areaPerPerson };
|
||||
}
|
||||
|
||||
export function hasEfficientHeatingSystem(
|
||||
heatingRating: number | null
|
||||
): boolean {
|
||||
return heatingRating != null && heatingRating >= 3;
|
||||
}
|
||||
|
||||
export function isInsulationAdequate(
|
||||
roofRating: number | null,
|
||||
wallRating: number | null
|
||||
): boolean {
|
||||
return (
|
||||
(roofRating != null && roofRating >= 3) ||
|
||||
(wallRating != null && wallRating >= 3)
|
||||
);
|
||||
}
|
||||
|
||||
export function meetsSapThreshold(currentSapPoints: number): boolean {
|
||||
return currentSapPoints >= 35;
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
getSpatialData,
|
||||
getNonIntrusiveSurvey,
|
||||
getDocument,
|
||||
getEnergyAssessmentFromS3
|
||||
getEnergyAssessmentFromS3,
|
||||
} from "../utils";
|
||||
import ConditionReport from "@/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport";
|
||||
|
||||
|
|
@ -122,11 +122,9 @@ const formatDate = (dateString: Date) => {
|
|||
});
|
||||
};
|
||||
|
||||
export default async function PreAssessmentReport(
|
||||
props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}
|
||||
) {
|
||||
export default async function PreAssessmentReport(props: {
|
||||
params: Promise<{ slug: string; propertyId: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
const conditionReportData = await getConditionReport(params.propertyId);
|
||||
|
|
@ -135,14 +133,19 @@ export default async function PreAssessmentReport(
|
|||
conditionReportData,
|
||||
propertyMeta.propertyType
|
||||
);
|
||||
const conditionReportMeta = await getDocument(
|
||||
{ uprn: String(propertyMeta.uprn), documentType: "ECO_CONDITION_REPORT" }
|
||||
);
|
||||
const conditionReportMeta = await getDocument({
|
||||
uprn: String(propertyMeta.uprn),
|
||||
documentType: "ECO_CONDITION_REPORT",
|
||||
});
|
||||
let conditionReport = { rooms: {} };
|
||||
if (conditionReportMeta && conditionReportMeta.s3JsonUri) {
|
||||
conditionReport = await getEnergyAssessmentFromS3(conditionReportMeta.s3JsonUri);
|
||||
conditionReport = await getEnergyAssessmentFromS3(
|
||||
conditionReportMeta.s3JsonUri
|
||||
);
|
||||
}
|
||||
|
||||
console.log("conditionReport", conditionReport);
|
||||
|
||||
const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn);
|
||||
|
||||
const retrofitFeatures = formatRetrofitFeatures(conditionReportData);
|
||||
|
|
@ -176,15 +179,14 @@ export default async function PreAssessmentReport(
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
Object.keys(conditionReportMeta).length > 0 &&
|
||||
<ConditionReport
|
||||
conditionReport={conditionReport}
|
||||
totalFloorArea={conditionReportData.totalFloorArea}
|
||||
{Object.keys(conditionReportMeta).length > 0 && (
|
||||
<ConditionReport
|
||||
conditionReport={conditionReport}
|
||||
totalFloorArea={conditionReportData.totalFloorArea}
|
||||
currentSapPoints={propertyMeta.currentSapPoints}
|
||||
conditionData={conditionReportData}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
|
||||
{nonIntrusiveSurvey && (
|
||||
<div>
|
||||
|
|
|
|||
118
src/app/shadcn_components/ui/drawer.tsx
Normal file
118
src/app/shadcn_components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue