From e6b0b702700139ac162f84857db0f8eeb7bb6e93 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Sep 2025 17:21:28 +0000 Subject: [PATCH 1/4] added decent homes tab button --- .../components/building-passport/Toolbar.tsx | 36 +++++---- src/app/db/surveyDB/schema/documents.ts | 76 ++++++++++++------- src/app/db/surveyDB/schema/surveyDB.ts | 26 ++++--- .../portfolio/[slug]/(portfolio)/layout.tsx | 12 ++- .../[propertyId]/assessment/page.tsx | 23 ------ .../[propertyId]/documents/DocumentsTable.tsx | 22 ++++-- .../[propertyId]/documents/page.tsx | 20 ++--- .../building-passport/[propertyId]/layout.tsx | 25 +++--- 8 files changed, 129 insertions(+), 111 deletions(-) diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 3ff861cf..b0759e4e 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -7,7 +7,7 @@ import { WrenchScrewdriverIcon, SunIcon, CircleStackIcon, - BoltIcon, + HeartIcon, } from "@heroicons/react/24/outline"; import { NavigationMenu, @@ -21,7 +21,7 @@ import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB"; interface ToolbarProps { propertyId: string; portfolioId: string; - conditionReport: getUploadedFile; + decentHomes: getUploadedFile; } const navigationMenuTriggerStyle = cva( @@ -55,7 +55,11 @@ const navigationMenuTriggerStyle = cva( ].join(" ") ); -export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarProps) { +export function Toolbar({ + propertyId, + portfolioId, + decentHomes, +}: ToolbarProps) { function handleClickSettings() { console.log("Settings were clicked, implement me"); } @@ -70,16 +74,6 @@ export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarPro ); - // const energyAssessmentsReportButton = ( - // - // - // Energy Assessment - // - // ); - const documentsButton = ( ); + const decentHomesButton = ( + + + Decent Homes + + ); + return ( {preAssessmentReportButton} + {/* We only show decent homes button if decent homes is not an empty object */} + {Object.keys(decentHomes).length > 0 && + decentHomes.uprn && + decentHomesButton} {solarAnalysisButton} {recommendationsButton} {documentsButton} - {/* {energyAssessmentsReportButton} */} + = { -// quidos_presite_note: "RdSAP Summary Report", -// charted_surveyor_report: "Chartered Surveyor Report", -// u_value_calculator_report: "U-Value Calculator Report", -// overwriting_u_value_declaration_form: "Overwriting U-Value Declaration Form", + // quidos_presite_note: "RdSAP Summary Report", + // charted_surveyor_report: "Chartered Surveyor Report", + // u_value_calculator_report: "U-Value Calculator Report", + // overwriting_u_value_declaration_form: "Overwriting U-Value Declaration Form", osmosis_condition_pas_2035_report: "Osmosis Condition Report (PAS 2035)", -// warm_homes_condition_pas_2035_report: "Warm Homes PAS 2035 Report", -// energy_performance_report_with_data: "EPC Report With Data", + // warm_homes_condition_pas_2035_report: "Warm Homes PAS 2035 Report", + // energy_performance_report_with_data: "EPC Report With Data", energy_performance_report_summary_information: "EPC Summary Report", lodgement_xml_needed_for_lodgement_to_like_trademark: "LIG XML", reduce_xml_needed_to_generate_full_sap_xml: "RdSAP XML", full_xml_needed_for_co_ordination: "Full SAP XML", -// floor_plan: "Floor Plan", -// occupancy_assessment: "Occupancy Assessment", + decent_homes_summary: "Decent Homes Summary", + decent_homes_property_meta: "Decent Homes Property Meta", + // decent_homes_energy_performance_report: "Decent Homes Energy Performance Report", + // decent_homes_energy_performance_report_summary_information: + // "Decent Homes Energy Performance Report Summary Information", + // floor_plan: "Floor Plan", + // occupancy_assessment: "Occupancy Assessment", }; // Map reportType → accepted file extensions -export const documentTypeFileTypes: Record = { -// quidos_presite_note: ".pdf", -// charted_surveyor_report: ".pdf", -// u_value_calculator_report: ".pdf", -// overwriting_u_value_declaration_form: ".pdf", +export const documentTypeFileTypes: Record< + ReportType, + ".pdf" | ".xml" | ".xml,.pdf" | ".json" +> = { + // quidos_presite_note: ".pdf", + // charted_surveyor_report: ".pdf", + // u_value_calculator_report: ".pdf", + // overwriting_u_value_declaration_form: ".pdf", osmosis_condition_pas_2035_report: ".pdf", -// warm_homes_condition_pas_2035_report: ".pdf", -// energy_performance_report_with_data: ".pdf", + // warm_homes_condition_pas_2035_report: ".pdf", + // energy_performance_report_with_data: ".pdf", energy_performance_report_summary_information: ".pdf", lodgement_xml_needed_for_lodgement_to_like_trademark: ".xml", reduce_xml_needed_to_generate_full_sap_xml: ".xml", full_xml_needed_for_co_ordination: ".xml", -// floor_plan: ".pdf", -// occupancy_assessment: ".pdf", + decent_homes_property_meta: ".json", + decent_homes_summary: ".json", + // floor_plan: ".pdf", + // occupancy_assessment: ".pdf", }; export const ReportTypeSchema = z.enum(REPORT_TYPES); // Map UI value -> DB enum NAME export const reportTypeToDbLabel: Record = { osmosis_condition_pas_2035_report: "ECO_CONDITION_REPORT", - energy_performance_report_summary_information: "ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION", + energy_performance_report_summary_information: + "ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION", lodgement_xml_needed_for_lodgement_to_like_trademark: "LIG_XML", reduce_xml_needed_to_generate_full_sap_xml: "RDSAP_XML", full_xml_needed_for_co_ordination: "FULLSAP_XML", + decent_homes_summary: "DECENT_HOMES_SUMMARY", + decent_homes_property_meta: "DECENT_HOMES_PROPERTY_META", }; // Optional reverse map (for reading from API): export const dbLabelToReportType: Record = { ECO_CONDITION_REPORT: "osmosis_condition_pas_2035_report", - ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION: "energy_performance_report_summary_information", + ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION: + "energy_performance_report_summary_information", LIG_XML: "lodgement_xml_needed_for_lodgement_to_like_trademark", RDSAP_XML: "reduce_xml_needed_to_generate_full_sap_xml", FULLSAP_XML: "full_xml_needed_for_co_ordination", -}; \ No newline at end of file + DECENT_HOMES_SUMMARY: "decent_homes_summary", + DECENT_HOMES_PROPERTY_META: "decent_homes_property_meta", +}; diff --git a/src/app/db/surveyDB/schema/surveyDB.ts b/src/app/db/surveyDB/schema/surveyDB.ts index 2f7c92de..65ea00bd 100644 --- a/src/app/db/surveyDB/schema/surveyDB.ts +++ b/src/app/db/surveyDB/schema/surveyDB.ts @@ -1,13 +1,15 @@ import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; import { pgEnum } from "drizzle-orm/pg-core"; - export const DB_REPORT_TYPES = [ - "ECO_CONDITION_REPORT", - "ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION", - "LIG_XML", - "RDSAP_XML", - "FULLSAP_XML", + "ECO_CONDITION_REPORT", + "ENERGY_PERFORMANCE_REPORT_SUMMARY_INFORMATION", + "LIG_XML", + "RDSAP_XML", + "FULLSAP_XML", + "DECENT_HOMES_RAW_DATA", + "DECENT_HOMES_PROPERTY_META", + "DECENT_HOMES_SUMMARY", ] as const; export const docTypeEnum = pgEnum("reporttype", DB_REPORT_TYPES); @@ -18,14 +20,18 @@ export const uploadedFiles = pgTable("uploaded_files", { s3JsonUri: text("s3_json_uri"), s3FileUri: text("s3_file_uri").notNull(), - docType: docTypeEnum("doc_type").notNull(), // enum used here ✅ + docType: docTypeEnum("doc_type").notNull(), // enum used here ✅ - s3FileUploadTimestamp: timestamp("s3_file_upload_timestamp", { withTimezone: true }).notNull(), - s3JsonUploadTimestamp: timestamp("s3_json_upload_timestamp", { withTimezone: true }), + s3FileUploadTimestamp: timestamp("s3_file_upload_timestamp", { + withTimezone: true, + }).notNull(), + s3JsonUploadTimestamp: timestamp("s3_json_upload_timestamp", { + withTimezone: true, + }), uprn: text("uprn").notNull(), }); -export type getUploadedFile = typeof uploadedFiles.$inferSelect +export type getUploadedFile = typeof uploadedFiles.$inferSelect; export type getUploadedFiles = getUploadedFile[]; diff --git a/src/app/portfolio/[slug]/(portfolio)/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/layout.tsx index 136fdb01..862f5e27 100644 --- a/src/app/portfolio/[slug]/(portfolio)/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/layout.tsx @@ -1,17 +1,15 @@ import { Toolbar } from "@/app/components/portfolio/Toolbar"; import { getPortfolio, getPortfolioScenarios } from "../utils"; -export default async function PortfolioLayout( - props: { - children: React.ReactNode; - params: Promise<{ slug: string; propertyId: string }>; - } -) { +export default async function PortfolioLayout(props: { + children: React.ReactNode; + params: Promise<{ slug: string; propertyId: string }>; +}) { const params = await props.params; const { // will be a page or nested layout - children + children, } = props; const portfolioId = params.slug; 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 6fe81848..084954b6 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/assessment/page.tsx @@ -1,7 +1,6 @@ import EpcCard from "@/app/components/building-passport/EpcCard"; import FeatureTable from "@/app/components/building-passport/FeatureTable"; import { - ConditionReportData, PropertyDetailsEpc, PropertyDetailsSpatial, PropertyMeta, @@ -23,7 +22,6 @@ import { getDocument, getEnergyAssessmentFromS3, } from "../utils"; -import ConditionReport from "@/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport"; interface PropertyDetailsCardProps { conditionReportData: PropertyDetailsEpc; @@ -133,18 +131,6 @@ export default async function PreAssessmentReport(props: { conditionReportData, propertyMeta.propertyType ); - const conditionReportMeta = await getDocument({ - uprn: String(propertyMeta.uprn), - documentType: "ECO_CONDITION_REPORT", - }); - let conditionReport = { rooms: {} }; - if (conditionReportMeta && conditionReportMeta.s3JsonUri) { - conditionReport = await getEnergyAssessmentFromS3( - conditionReportMeta.s3JsonUri - ); - } - - console.log("conditionReport", conditionReport); const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn); @@ -179,15 +165,6 @@ export default async function PreAssessmentReport(props: { - {Object.keys(conditionReportMeta).length > 0 && ( - - )} - {nonIntrusiveSurvey && (
Non-Intrusive Survey
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx index 53c9cd94..a6956fc9 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx @@ -1,11 +1,16 @@ "use client"; import React from "react"; -import { Table, TableBody, TableRow, TableCell } from "@/app/shadcn_components/ui/table"; +import { + Table, + TableBody, + TableRow, + TableCell, +} from "@/app/shadcn_components/ui/table"; import { DocumentSection } from "./DocumentSection"; import { type ReportType, REPORT_TYPES, - dbLabelToReportType, // <-- import the map + dbLabelToReportType, // <-- import the map } from "@/app/db/surveyDB/schema/documents"; import type { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB"; @@ -14,7 +19,10 @@ type Props = { uploadedFilesData: getUploadedFile[]; }; -export const DocumentsTable: React.FC = ({ uprn, uploadedFilesData }) => { +export const DocumentsTable: React.FC = ({ + uprn, + uploadedFilesData, +}) => { const filesByType = React.useMemo(() => { const map: Partial> = {}; @@ -26,7 +34,7 @@ export const DocumentsTable: React.FC = ({ uprn, uploadedFilesData }) => } // newest first within each group - Object.values(map).forEach(arr => + Object.values(map).forEach((arr) => arr!.sort( (a, b) => new Date(b.s3FileUploadTimestamp as any).getTime() - @@ -37,18 +45,20 @@ export const DocumentsTable: React.FC = ({ uprn, uploadedFilesData }) => return map; }, [uploadedFilesData]); + console.log("filesByType", filesByType); + return ( {REPORT_TYPES.map((reportType) => { const filesForType = filesByType[reportType] ?? []; - console.log("reportType", reportType) + console.log("reportType", reportType); return ( diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx index 1ded30a0..799142af 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx @@ -5,22 +5,17 @@ import { surveyDB } from "@/app/db/surveyDB/connection"; import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB"; import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB"; - -async function getDocuments( - uprn: number -): Promise< getUploadedFiles> { +async function getDocuments(uprn: number): Promise { const result = surveyDB.query.uploadedFiles.findMany({ where: eq(uploadedFiles.uprn, String(uprn)), }); - + return result; } -export default async function DocumentsPage( - props: { - params: Promise<{ slug: string; propertyId: string }>; - } -) { +export default async function DocumentsPage(props: { + params: Promise<{ slug: string; propertyId: string }>; +}) { const params = await props.params; // Get the property UPRN const propertyId = params.propertyId; @@ -31,6 +26,8 @@ export default async function DocumentsPage( const propertyMeta = await getPropertyMeta(propertyId); const uploadedFiles = await getDocuments(propertyMeta.uprn); + console.log("Uploaded files:", uploadedFiles); + return ( <>
@@ -38,7 +35,7 @@ export default async function DocumentsPage( Core Survey Documents
- @@ -51,4 +48,3 @@ export default async function DocumentsPage( ); } - diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx index 4a313f1d..5da4b86b 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx @@ -12,17 +12,15 @@ function EstimatedDataNotification() { ); } -export default async function DashboardLayout( - props: { - children: React.ReactNode; - params: Promise<{ slug: string; propertyId: string }>; - } -) { +export default async function DashboardLayout(props: { + children: React.ReactNode; + params: Promise<{ slug: string; propertyId: string }>; +}) { const params = await props.params; const { // will be a page or nested layout - children + children, } = props; const propertyId = params.propertyId ?? ""; @@ -32,9 +30,10 @@ export default async function DashboardLayout( const propertyMeta = await getPropertyMeta(params.propertyId); // We check if we have an uploaded condition report and if so, we show the condition tab. Otherwise, we // don't show it - const conditionReport = await getDocument( - { uprn: String(propertyMeta.uprn), documentType: "ECO_CONDITION_REPORT" } - ); + const decentHomes = await getDocument({ + uprn: String(propertyMeta.uprn), + documentType: "DECENT_HOMES_SUMMARY", + }); if (!propertyId && propertyId !== "0") { throw Error("Invalid propertyId"); @@ -58,7 +57,11 @@ export default async function DashboardLayout(

{propertyMeta.postcode}

- +
{propertyMeta.detailsEpc.estimated && } {children} From 65ce0ecb2acfb58861b07bde6d53ae8077a4ff6f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Sep 2025 20:43:08 +0000 Subject: [PATCH 2/4] new decent homes ui --- package-lock.json | 160 ++++++ package.json | 2 + .../[propertyId]/decent-homes/page.tsx | 515 ++++++++++++++++++ src/app/shadcn_components/ui/scroll-area.tsx | 48 ++ src/app/shadcn_components/ui/tabs.tsx | 55 ++ 5 files changed, 780 insertions(+) create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx create mode 100644 src/app/shadcn_components/ui/scroll-area.tsx create mode 100644 src/app/shadcn_components/ui/tabs.tsx diff --git a/package-lock.json b/package-lock.json index 9186c518..aceec295 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,11 @@ "@radix-ui/react-hover-card": "^1.0.6", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", @@ -3444,6 +3446,73 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@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-scroll-area/node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/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-scroll-area/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-select": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-1.2.2.tgz", @@ -4022,6 +4091,97 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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-tabs/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-tabs/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-tabs/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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-toast": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", diff --git a/package.json b/package.json index 78c9c447..a8f56752 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "@radix-ui/react-hover-card": "^1.0.6", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-navigation-menu": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^1.2.2", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx new file mode 100644 index 00000000..825b63e7 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx @@ -0,0 +1,515 @@ +import { + getPropertyMeta, + getDocument, + getEnergyAssessmentFromS3, +} from "../utils"; + +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/app/shadcn_components/ui/card"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, +} from "@/app/shadcn_components/ui/tabs"; +import { Wrench } from "lucide-react"; + +const DISPLAY_NAMES: Record = { + // Criterion A - HHSRS hazards + damp_and_mould_growth: "Damp and Mould Growth", + excess_cold: "Excess Cold", + excess_heat: "Excess Heat", + asbestos_and_mm_fibres: "Asbestos and MM Fibres", + biocides: "Biocides", + carbon_monoxide: "Carbon Monoxide", + lead: "Lead", + radiation: "Radiation", + uncombusted_fuel_gas: "Uncombusted Fuel Gas", + volatile_organic_compounds: "Volatile Organic Compounds", + crowding_and_space: "Crowding and Space", + entry_by_intruders: "Entry by Intruders", + lighting: "Lighting", + noise: "Noise", + domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse", + food_safety: "Food Safety", + personal_hygiene_sanitation_and_drainage: + "Personal Hygiene, Sanitation, and Drainage", + water_supply: "Water Supply", + falls_associated_with_baths: "Falls Associated with Baths", + falls_on_level_surfaces: "Falls on Level Surfaces", + falls_on_stairs_and_steps: "Falls on Stairs and Steps", + falls_between_levels: "Falls Between Levels", + electrical_hazards: "Electrical Hazards", + fire: "Fire", + flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials", + collision_and_entrapment: "Collision and Entrapment", + explosions: "Explosions", + ergonomics: "Ergonomics", + structural_collapse_and_falling_elements: + "Structural Collapse and Falling Elements", + + // Criterion B - Key building components + wall_structure: "Wall Structure", + lintels: "Lintels", + wall_finish: "Wall Finish", + roof_structure: "Roof Structure", + roof_finish: "Roof Finish", + chimneys: "Chimneys", + windows: "Windows", + external_doors: "External Doors", + heating_other: "Other Heating Systems", + electrical_systems: "Electrical Systems", + kitchen: "Kitchen", + bathroom: "Bathroom", + + // Criterion C - Modern facilities + kitchen_less_than_20_years_old: "Kitchen Less Than 20 Years Old", + kitchen_adequate_space_and_layout: "Kitchen Has Adequate Space and Layout", + bathroom_less_than_30_years_old: "Bathroom Less Than 30 Years Old", + bathroom_wc_appropriately_located: "Bathroom/WC Appropriately Located", + adequate_external_noise_insulation: "Adequate External Noise Insulation", + + // Criterion D - Thermal comfort + efficient_heating_system_type: "Efficient Heating System Type", + efficient_heating_distribution: "Efficient Heating Distribution", + loft_insulation_sufficient: "Loft Insulation Sufficient", + wall_insulation_sufficient: "Wall Insulation Sufficient", +}; + +const SUB_ITEMS_TEXT: Record = { + // Criterion A - Hazards (keep as-is, not replacements) + damp_and_mould_growth: "Damp and Mould Growth", + excess_cold: "Excess Cold", + excess_heat: "Excess Heat", + asbestos_and_mm_fibres: "Asbestos and MM Fibres", + biocides: "Biocides", + carbon_monoxide: "Carbon Monoxide", + lead: "Lead", + radiation: "Radiation", + uncombusted_fuel_gas: "Uncombusted Fuel Gas", + volatile_organic_compounds: "Volatile Organic Compounds", + crowding_and_space: "Crowding and Space", + entry_by_intruders: "Entry by Intruders", + lighting: "Lighting", + noise: "Noise", + domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse", + food_safety: "Food Safety", + personal_hygiene_sanitation_and_drainage: + "Personal Hygiene, Sanitation, and Drainage", + water_supply: "Water Supply", + falls_associated_with_baths: "Falls Associated with Baths", + falls_on_level_surfaces: "Falls on Level Surfaces", + falls_on_stairs_and_steps: "Falls on Stairs and Steps", + falls_between_levels: "Falls Between Levels", + electrical_hazards: "Electrical Hazards", + fire: "Fire", + flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials", + collision_and_entrapment: "Collision and Entrapment", + explosions: "Explosions", + ergonomics: "Ergonomics", + structural_collapse_and_falling_elements: + "Structural Collapse and Falling Elements", + + // Criterion B - Key components + "Wall Structure in External Area": "Wall Structure Renewal", + "Lintels in External Area": "Lintel Renewal", + "Wall Finish 1 in External Area": "Wall Finish Renewal", + "Brickwork Pointing in External Area": "Brickwork Pointing Renewal", + "Roof Structure 1 in External Area": "Roof Structure Renewal", + "Fascia / Soffit / Bargeboard in External Area": + "Fascia / Soffit / Bargeboard Renewal", + "Gutters in External Area": "Gutter Renewal", + "Downpipes in External Area": "Downpipe Renewal", + "Roof Covering 1 in External Area": "Roof Covering Replacement", + "Chimneys in External Area": "Chimney Renewal", + "Windows in Property": "Window Replacement", + "Windows 1 in External Area": "Window Replacement", + "Type and Location of Front Door in Property": "Front Door Replacement", + "Back and Side Doors 1 in External Area": "Door Replacement", + "Back and Side Doors 2 in External Area": "Door Replacement", + "Type of Water Heating in Property": "Water Heating System Replacement", + "Electrics Required in Property": "Electrical System Renewal", + "Adequacy of Kitchen and Type in Property": "Kitchen Renewal", + "Adequacy of Bathroom Location in Property": "Bathroom Renewal", + + // Criterion C - Modern facilities + kitchen_less_than_20_years_old: "Kitchen Replacement", + kitchen_adequate_space_and_layout: "Kitchen Layout Upgrade", + bathroom_less_than_30_years_old: "Bathroom Replacement", + bathroom_wc_appropriately_located: "Bathroom/WC Layout Upgrade", + adequate_external_noise_insulation: "Noise Insulation Upgrade", + + // Criterion D - Thermal comfort + efficient_heating_system_type: "Heating System Upgrade", + efficient_heating_distribution: "Heating Distribution Upgrade", + loft_insulation_sufficient: "Loft Insulation Upgrade", + wall_insulation_sufficient: "Wall Insulation Upgrade", +}; + +const LABEL_MAP: Record = { + pass: "Pass", + fail: "Fail", + no_data: "Not Assessed", +}; + +function StatusBadge({ status }: { status: string }) { + const colors = + status === "pass" + ? "bg-green-600 hover:bg-green-700" + : status === "fail" + ? "bg-red-700 hover:bg-red-800" + : "bg-gray-500 hover:bg-gray-600"; + + return ( + + {LABEL_MAP[status]} + + ); +} + +// urgency badge +function UrgencyBadge({ label }: { label: string }) { + const colorMap: Record = { + Overdue: "bg-red-600", + "0–6 months": "bg-orange-500", + "6–12 months": "bg-yellow-500", + ">12 months": "bg-green-600", + }; + return ( + + {label} + + ); +} + +function CriterionContent({ + title, + items, +}: { + title: string; + items: { sub_variable: string; result: string }[]; +}) { + const sortedItems = [...items].sort((a, b) => { + const order = { fail: 0, no_data: 1, pass: 2 }; + return ( + order[a.result as keyof typeof order] - + order[b.result as keyof typeof order] + ); + }); + + return ( + + + {title} + + +
    + {sortedItems.map((item, idx) => ( +
  • + + {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable} + + +
  • + ))} +
+
+
+ + ); +} + +function ReplacementsContent({ + items, +}: { + items: { sub_variable: string; expiry_date: string | null }[]; +}) { + const today = new Date(); + const groups: Record< + string, + { sub_variable: string; expiry: Date; remaining: string }[] + > = { + Overdue: [], + "0–6 months": [], + "6–12 months": [], + ">12 months": [], + }; + + items.forEach((item) => { + if (!item.expiry_date) return; + const expiry = new Date(item.expiry_date); + const diffMs = expiry.getTime() - today.getTime(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + const years = Math.floor(diffMonths / 12); + const months = Math.floor(diffMonths % 12); + const remaining = + diffMs < 0 + ? "Expired" + : `${years > 0 ? `${years}y ` : ""}${months}m remaining`; + + if (diffMs < 0) { + groups.Overdue.push({ + sub_variable: SUB_ITEMS_TEXT[item.sub_variable], + expiry, + remaining, + }); + } else if (diffMonths <= 6) { + groups["0–6 months"].push({ + sub_variable: SUB_ITEMS_TEXT[item.sub_variable], + expiry, + remaining, + }); + } else if (diffMonths <= 12) { + groups["6–12 months"].push({ + sub_variable: SUB_ITEMS_TEXT[item.sub_variable], + expiry, + remaining, + }); + } else { + groups[">12 months"].push({ + sub_variable: SUB_ITEMS_TEXT[item.sub_variable], + expiry, + remaining, + }); + } + }); + + // sort each group by expiry date ascending (soonest first) + Object.values(groups).forEach((comps) => { + comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime()); + }); + + // order of groups + const groupOrder: (keyof typeof groups)[] = [ + "Overdue", + "0–6 months", + "6–12 months", + ">12 months", + ]; + + return ( + + + Upcoming Replacements + + + {groupOrder.map((urgency) => + groups[urgency].length > 0 ? ( +
+
+ + + {groups[urgency].length} items + +
+
    + {groups[urgency].map((comp, idx) => ( +
  • + + {DISPLAY_NAMES[comp.sub_variable] ?? comp.sub_variable} + + + {comp.remaining} + +
  • + ))} +
+
+ ) : null + )} +
+
+ + ); +} + +function DecentHomesSummary({ + decentHomes, + decentHomesMeta, +}: { + decentHomes: { + uprn: number; + creation_date: string; + criterion_a: string; + criterion_b: string; + criterion_c: string; + criterion_d: string; + decent_homes: string; + }; + decentHomesMeta: { + criteria: string; + sub_variable: string; + result: string; + expiry_date?: string | null; + }[]; +}) { + const overallPass = decentHomes.decent_homes === "pass"; + const lastUpdated = new Date(decentHomes.creation_date).toLocaleDateString( + "en-GB", + { + day: "numeric", + month: "long", + year: "numeric", + } + ); + + const criteriaGroups: Record< + string, + { sub_variable: string; result: string }[] + > = { + A: [], + B: [], + C: [], + D: [], + }; + + const replacements: { sub_variable: string; expiry_date: string | null }[] = + []; + + decentHomesMeta.forEach((item) => { + if (criteriaGroups[item.criteria]) { + criteriaGroups[item.criteria].push({ + sub_variable: item.sub_variable, + result: item.result, + }); + } + if (item.expiry_date) { + replacements.push({ + sub_variable: item.sub_variable, + expiry_date: item.expiry_date, + }); + } + }); + + const soonCount = replacements.filter((r) => { + if (!r.expiry_date) return false; + const expiry = new Date(r.expiry_date); + const diffMs = expiry.getTime() - new Date().getTime(); + return diffMs < 1000 * 60 * 60 * 24 * 365; + }).length; + + return ( +
+ + + + Decent Homes Assessment + + + + + {overallPass ? "Pass" : "Fail"} + +

Last updated: {lastUpdated}

+
+
+ + + + Criterion A + Criterion B + Criterion C + Criterion D + + + Replacements + {soonCount > 0 && ( + + {soonCount} + + )} + + + + + + + + + + + + + + + + + + + +
+ ); +} + +export default async function DecentHomesPage(props: { + params: Promise<{ slug: string; propertyId: string }>; +}) { + const params = await props.params; + const propertyMeta = await getPropertyMeta(params.propertyId); + + const decentHomesSummary = await getDocument({ + uprn: String(propertyMeta.uprn), + documentType: "DECENT_HOMES_SUMMARY", + }); + + const decentPropertyMeta = await getDocument({ + uprn: String(propertyMeta.uprn), + documentType: "DECENT_HOMES_PROPERTY_META", + }); + + if ( + Object.keys(decentHomesSummary).length === 0 || + Object.keys(decentPropertyMeta).length === 0 || + !decentHomesSummary.s3JsonUri || + !decentPropertyMeta.s3JsonUri + ) { + throw new Error("Decent Homes data is missing"); + } + + const decentHomesMeta = await getEnergyAssessmentFromS3( + decentPropertyMeta.s3JsonUri + ); + const decentHomes = await getEnergyAssessmentFromS3( + decentHomesSummary.s3JsonUri + ); + + return ( + + ); +} diff --git a/src/app/shadcn_components/ui/scroll-area.tsx b/src/app/shadcn_components/ui/scroll-area.tsx new file mode 100644 index 00000000..32cf968d --- /dev/null +++ b/src/app/shadcn_components/ui/scroll-area.tsx @@ -0,0 +1,48 @@ +"use client"; + +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; + +import { cn } from "@/lib/utils"; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/app/shadcn_components/ui/tabs.tsx b/src/app/shadcn_components/ui/tabs.tsx new file mode 100644 index 00000000..fb01e0ab --- /dev/null +++ b/src/app/shadcn_components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; From 66a43a19b8f32aa8a97d3d192640628c2278a202 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 23 Sep 2025 21:37:50 +0000 Subject: [PATCH 3/4] Added the summary dashboard --- .../decent-homes/DecentHomesDashboard.tsx | 445 +++++++++++------- .../[slug]/(portfolio)/decent-homes/page.tsx | 155 +++--- 2 files changed, 339 insertions(+), 261 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx b/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx index eae2e1cc..8355576c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/decent-homes/DecentHomesDashboard.tsx @@ -9,16 +9,20 @@ import { DrawerHeader, DrawerTitle, } from "@/app/shadcn_components/ui/drawer"; +import { Badge } from "@/app/shadcn_components/ui/badge"; +// Types interface PropertySummary { id: bigint; uprn: bigint; address: string; postcode: string; - has_condition_data: boolean; - passes_decent_homes: boolean; - category1: Record; - category2: Record; + decent_homes: "pass" | "fail" | "no_data"; + criterion_a: "pass" | "fail" | "no_data"; + criterion_b: "pass" | "fail" | "no_data"; + criterion_c: "pass" | "fail" | "no_data"; + criterion_d: "pass" | "fail" | "no_data"; + replacements?: { sub_variable: string; expiry_date: string | null }[]; } interface Props { @@ -26,49 +30,170 @@ interface Props { portfolioId: string; } -type ReasonKey = - | "damp_passes" - | "structure_passes" - | "heating_passes" - | "sap_passes" - | "thermal_comfort_passes" - | "defects_pass" - | "windows_pass" - | "kitchen_pass" - | "bathroom_pass" - | "space_pass"; +// --- Urgency Badge for notifications --- +function UrgencyBadge({ label }: { label: string }) { + const colorMap: Record = { + Overdue: "bg-red-600", + "0–6 months": "bg-orange-500", + "6–12 months": "bg-yellow-500", + ">12 months": "bg-green-600", + }; + return ( + + {label} + + ); +} -const reasonLabelMap: Record = { - damp_passes: "Damp", - structure_passes: "Structural Issues", - heating_passes: "Heating", - sap_passes: "SAP Rating", - thermal_comfort_passes: "Thermal Comfort", - defects_pass: "General Disrepair", - windows_pass: "Windows", - kitchen_pass: "Kitchen", - bathroom_pass: "Bathroom", - space_pass: "Space Standard", -}; +function NotificationsPanel({ + data, + portfolioId, +}: { + data: PropertySummary[]; + portfolioId: string; +}) { + const today = new Date(); + const groups: Record< + string, + { + property: PropertySummary; + sub_variable: string; + expiry: Date; + remaining: string; + }[] + > = { + Overdue: [], + "0–6 months": [], + "6–12 months": [], + ">12 months": [], + }; + data.forEach((p) => { + p.replacements?.forEach((r) => { + if (!r.expiry_date) return; + const expiry = new Date(r.expiry_date); + const diffMs = expiry.getTime() - today.getTime(); + const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30); + + const years = Math.floor(Math.abs(diffMonths) / 12); + const months = Math.floor(Math.abs(diffMonths) % 12); + + let remaining = ""; + if (diffMs < 0) { + remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`; + } else { + remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`; + } + + if (diffMs < 0) { + groups.Overdue.push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } else if (diffMonths <= 6) { + groups["0–6 months"].push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } else if (diffMonths <= 12) { + groups["6–12 months"].push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } else { + groups[">12 months"].push({ + property: p, + sub_variable: r.sub_variable, + expiry, + remaining, + }); + } + }); + }); + + const groupOrder: (keyof typeof groups)[] = [ + "Overdue", + "0–6 months", + "6–12 months", + ">12 months", + ]; + + return ( +
+

+ Upcoming Replacements +

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

Decent Homes Summary

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

+ Decent Homes Portfolio Summary +

-

- Data completeness: {dataCompleteness}% -

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

- Top Reasons for Failing -

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

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

-

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

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