diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b092302..e11255b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,9 +13,13 @@ "customizations": { "vscode": { "settings": { - "files.defaultWorkspace": "/workspaces/assessment-model" + "files.defaultWorkspace": "/workspaces/assessment-model", + "editor.formatOnSave": true, + "editor.tabSize": 2, + "editor.insertSpaces": true }, "extensions": [ + "esbenp.prettier-vscode" ] } } diff --git a/package-lock.json b/package-lock.json index 6b8e824..9186c51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@hookform/resolvers": "^3.9.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-hover-card": "^1.0.6", "@radix-ui/react-label": "^2.1.0", @@ -39,7 +39,6 @@ "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.3", "esbuild": "^0.25.8", - "eslint": "8.41.0", "eslint-config-next": "13.4.3", "lucide-react": "^0.233.0", "next": "^15.4.2", @@ -56,6 +55,7 @@ "tailwindcss-animate": "^1.0.6", "tsx": "^4.20.3", "typescript": "5.0.4", + "vaul": "^1.1.2", "xlsx": "^0.18.5", "zod": "^3.23.8" }, @@ -66,6 +66,8 @@ "cypress": "^14.5.3", "cypress-social-logins": "^1.14.1", "dotenv": "^16.3.1", + "eslint": "^8.57.1", + "prettier": "^3.6.2", "start-server-and-test": "^2.0.0" } }, @@ -1733,9 +1735,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", - "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1850,13 +1852,13 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -2942,20 +2944,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", - "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -2977,6 +2979,78 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -5480,6 +5554,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -8145,28 +8225,29 @@ } }, "node_modules/eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", - "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.41.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.5.2", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -8176,7 +8257,6 @@ "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", @@ -8186,9 +8266,8 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -11735,6 +11814,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -14075,6 +14170,19 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/package.json b/package.json index 51f4e13..78c9c44 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@hookform/resolvers": "^3.9.1", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-hover-card": "^1.0.6", "@radix-ui/react-label": "^2.1.0", @@ -45,7 +45,6 @@ "drizzle-kit": "^0.31.4", "drizzle-orm": "^0.44.3", "esbuild": "^0.25.8", - "eslint": "8.41.0", "eslint-config-next": "13.4.3", "lucide-react": "^0.233.0", "next": "^15.4.2", @@ -62,6 +61,7 @@ "tailwindcss-animate": "^1.0.6", "tsx": "^4.20.3", "typescript": "5.0.4", + "vaul": "^1.1.2", "xlsx": "^0.18.5", "zod": "^3.23.8" }, @@ -72,6 +72,8 @@ "cypress": "^14.5.3", "cypress-social-logins": "^1.14.1", "dotenv": "^16.3.1", + "eslint": "^8.57.1", + "prettier": "^3.6.2", "start-server-and-test": "^2.0.0" } } diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 2468f41..3ff861c 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -70,15 +70,15 @@ export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarPro ); - const energyAssessmentsReportButton = ( - - - Energy Assessment - - ); + // const energyAssessmentsReportButton = ( + // + // + // Energy Assessment + // + // ); const documentsButton = ( - Summary + Retrofit Summary + + Decent Homes + + + {/* Measures - + */} ; + category2: Record; +} + +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 = { + 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 = ({ data, portfolioId }) => { + console.log("Data received:", data); + 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 openDrawer = (group: PropertySummary[], title: string) => { + setSelectedGroup(group); + setSelectedTitle(title); + 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 +
+ +

+ Data completeness: {dataCompleteness}% +

+
+
+
+
+ +
+ + 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" + > + +
+ Category 1 Risks +
+
+ {failedC1.length} +
+
+
+ + + 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" + > + +
+ Category 2 Risks +
+
+ {failedC2.length} +
+
+
+ + + 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" + > + +
+ No Condition Data +
+
+ {missing.length} +
+
+
+ + 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" + > + +
+ Passes Decent Homes +
+
+ {passed.length} +
+
+
+
+ +
+

+ Top Reasons for Failing +

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

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

+

+ Failing in {count}{" "} + properties +

+
+
+ ))} +
+
+ + + + + {selectedTitle} + +
    + {selectedGroup.map((p) => ( +
  • +
    + {p.address} ({p.postcode}) +
    +
    + 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 +
    + )} + +
  • + ))} +
+
+
+
+ ); +}; + +export default DecentHomesDashboard; diff --git a/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx b/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx new file mode 100644 index 0000000..1ebcf8d --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/decent-homes/page.tsx @@ -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 { + 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> { + 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(); + 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 ( +
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx index ddd0afa..9c7709d 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport.tsx @@ -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, @@ -17,6 +9,30 @@ import { AccordionTrigger, } from "@/app/shadcn_components/ui/accordion"; import clsx from "clsx"; +import { AlertTriangle } from "lucide-react"; +import { PropertyDetailsEpc } from "@/app/db/schema/property"; +import { + getAllRoomData, + getRoomsWithDamp, + getRoomsWithDefects, + getRoomsWithBadWindows, + areAllWindowsOk, + getElevationsWithIssues, + hasSufficientSpace, + hasEfficientHeatingSystem, + isInsulationAdequate, + meetsSapThreshold, +} from "./decent_homes_utils"; + +type Rating = 1 | 2 | 3 | 4 | 5; + +const RatingMap: Record = { + 1: "Very Poor", + 2: "Poor", + 3: "Average", + 4: "Good", + 5: "Very Good", +}; function ChecklistItem({ label, @@ -31,18 +47,19 @@ function ChecklistItem({ alert?: boolean; roomsWithIssues?: [string, any][]; }) { - const icon = passed === true ? ( - - ) : passed === false ? ( - - ) : ( - - ); + const icon = + passed === true ? ( + + ) : passed === false ? ( + + ) : ( + + ); return (
@@ -59,7 +76,10 @@ function ChecklistItem({
{roomsWithIssues.length > 0 && ( - + {roomsWithIssues.map(([roomName, room]: any) => ( {formatRoomName(roomName)} @@ -67,23 +87,39 @@ function ChecklistItem({
{room.room_info?.overall_condition_of_the_room && (
- Condition: {room.room_info.overall_condition_of_the_room} + Condition:{" "} + {room.room_info.overall_condition_of_the_room}
)} - {room.room_info?.does_the_room_have_any_defects && ( -
- Defects: {room.room_info.does_the_room_have_any_defects} + {room.room_info?.does_the_room_have_any_defects === "Yes" && ( +
+ +
+
+ Defect reported +
+ {room.room_info?.description_of_defect && ( +
+ {room.room_info.description_of_defect} +
+ )} +
)} {room.room_info?.ventilation_info - ?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room !== - undefined && ( -
- Damp/Mould:{" "} - {room.room_info.ventilation_info - .are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room - ? "Yes" - : "No"} + ?.location_of_any_damp_or_mould && ( +
+ + + Damp/Mould Location:{" "} + { + room.room_info.ventilation_info + .location_of_any_damp_or_mould + } + + (Severity: Average) + +
)} {room.room_info?.windows_info?.condition_of_the_windows && ( @@ -109,98 +145,57 @@ function formatRoomName(name: string) { .replace("Room Info", "Room"); } -function getRecommendedOccupants(bedrooms: number): number { - if (bedrooms <= 0) return 0; - if (bedrooms === 1) return 2; - if (bedrooms === 2) return 4; - if (bedrooms === 3) return 6; - return 7; // 4 or more -} - export default function ConditionReport({ conditionReport, - totalFloorArea + totalFloorArea, + currentSapPoints, + conditionData, }: { conditionReport: { rooms: Record; [key: string]: any; - }, + }; totalFloorArea: number; + currentSapPoints: number; + conditionData: PropertyDetailsEpc; }) { + const allRoomData = getAllRoomData(conditionReport.rooms); + const roomsWithDamp = getRoomsWithDamp(allRoomData); + const roomsWithDefects = getRoomsWithDefects(allRoomData); + const roomsWithBadWindows = getRoomsWithBadWindows(allRoomData); + const windowsOk = areAllWindowsOk(allRoomData); - // Documentation on decent home standards can be found here: - // https://assets.publishing.service.gov.uk/media/5a7968b740f0b63d72fc5926/138355.pdf - - const rooms = conditionReport.rooms; - - const allRoomData = [ - ...Object.entries(rooms).filter(([k, 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]), - ]; - - const hasDampIssues = allRoomData.some( - ([, room]: any) => - room.room_info?.ventilation_info - ?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room + const elevationsWithIssues = getElevationsWithIssues( + conditionReport.access_and_elevations ); - const hasDefects = allRoomData.some( - ([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes" + const { + isSufficient: enoughSpace, + occupantsToUse, + numberOfBedrooms, + areaPerPerson, + } = hasSufficientSpace(conditionReport, totalFloorArea, allRoomData); + + const sapOk = meetsSapThreshold(currentSapPoints); + const efficientHeating = hasEfficientHeatingSystem( + conditionData.heatingRating ); + const insulationOk = isInsulationAdequate( + conditionData.roofRating, + conditionData.wallsRating + ); + const thermalComfortOk = efficientHeating && insulationOk; - const windowsOk = 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; - }); - - const heatingWorking = - conditionReport.heating_system?.general_condition - ?.is_the_heating_system_in_working_order === true; - - const kitchenOk = rooms.kitchen?.room_info?.overall_condition_of_the_room === "Good"; - - const bathroomsOk = Array.isArray(rooms.bathrooms) && - rooms.bathrooms.length > 0 && - rooms.bathrooms.every( - (b: any) => - b?.room_info?.overall_condition_of_the_room === "Good" - ) - - const roomsWithDefects = allRoomData.filter( - ([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes" + 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 roomsWithDamp = 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 - ); - - const roomsWithBadWindows = 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"; - } - ); - - // Check if the property has adequate space - // We've seen a case where the number of adult occupants + child occupants is greater than the total_number_of_occupants so we - // take the biggest of the two - 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 numberOfBedrooms = Array.isArray(rooms.bedrooms) - ? rooms.bedrooms.length - : 0; - - const occupantsToUse = Math.max(totalAdults + totalChildren, totalOccupants); - - const maxOccupants = getRecommendedOccupants(numberOfBedrooms); - const areaPerPerson = totalFloorArea / occupantsToUse; - const hasSufficientSpace = (occupantsToUse <= maxOccupants) && (areaPerPerson >= 20); + const hasDefects = roomsWithDefects.length > 0; return (
@@ -208,50 +203,132 @@ export default function ConditionReport({ Decent Homes Checklist - - 0} - roomsWithIssues={roomsWithDamp} - /> - - - - - - + +
+
+

+ Category 1 Hazards +

+ + 0 + ? "Structural issues identified" + : "No major structural issues identified" + } + passed={elevationsWithIssues.length === 0} + alert={elevationsWithIssues.length > 0} + roomsWithIssues={elevationsWithIssues} + /> + + 0 + ? "Signs of damp or mould present" + : "No signs of damp or mould" + } + passed={roomsWithDamp.length === 0} + alert={roomsWithDamp.length > 0} + roomsWithIssues={roomsWithDamp} + /> + + + + + + +
+ +
+

+ Category 2 Hazards +

+ + + + + + + + + + +
+
- -
); } diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/decent_homes_utils.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/decent_homes_utils.tsx new file mode 100644 index 0000000..f81a78f --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/decent_homes_utils.tsx @@ -0,0 +1,137 @@ +// utils/decentHomeChecks.ts + +export function getAllRoomData(rooms: Record) { + 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; +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx index ddce32a..6fe8184 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -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,15 +133,18 @@ 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.rooms.utility) + console.log("conditionReport", conditionReport); const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn); @@ -167,6 +168,7 @@ export default async function PreAssessmentReport(
- { - Object.keys(conditionReportMeta).length > 0 && - } + {Object.keys(conditionReportMeta).length > 0 && ( + + )} {nonIntrusiveSurvey && (
diff --git a/src/app/shadcn_components/ui/drawer.tsx b/src/app/shadcn_components/ui/drawer.tsx new file mode 100644 index 0000000..6a0ef53 --- /dev/null +++ b/src/app/shadcn_components/ui/drawer.tsx @@ -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) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}