mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Merge pull request #88 from Hestia-Homes/main
Implemented new version of decent homes ui
This commit is contained in:
commit
27b1d2abcb
15 changed files with 1279 additions and 372 deletions
160
package-lock.json
generated
160
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
// const energyAssessmentsReportButton = (
|
||||
// <NavigationMenuLink
|
||||
// className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
// href={`/portfolio/${portfolioId}/building-passport/${propertyId}/energy-assessment`}
|
||||
// >
|
||||
// <BoltIcon className="h-4 w-4 mr-2" />
|
||||
// Energy Assessment
|
||||
// </NavigationMenuLink>
|
||||
// );
|
||||
|
||||
const documentsButton = (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
|
|
@ -110,6 +104,16 @@ export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarPro
|
|||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
const decentHomesButton = (
|
||||
<NavigationMenuLink
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/decent-homes`}
|
||||
>
|
||||
<HeartIcon className="h-4 w-4 mr-2" />
|
||||
Decent Homes
|
||||
</NavigationMenuLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<NavigationMenu>
|
||||
<NavigationMenuLink
|
||||
|
|
@ -122,10 +126,14 @@ export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarPro
|
|||
|
||||
<NavigationMenuList>
|
||||
{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} */}
|
||||
|
||||
<NavigationMenuItem
|
||||
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
|
||||
onClick={handleClickSettings}
|
||||
|
|
|
|||
|
|
@ -2,72 +2,92 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const REPORT_TYPES = [
|
||||
// "quidos_presite_note",
|
||||
// "charted_surveyor_report",
|
||||
// "u_value_calculator_report",
|
||||
// "overwriting_u_value_declaration_form",
|
||||
// "quidos_presite_note",
|
||||
// "charted_surveyor_report",
|
||||
// "u_value_calculator_report",
|
||||
// "overwriting_u_value_declaration_form",
|
||||
"osmosis_condition_pas_2035_report",
|
||||
// "warm_homes_condition_pas_2035_report",
|
||||
// "energy_performance_report_with_data",
|
||||
// "warm_homes_condition_pas_2035_report",
|
||||
// "energy_performance_report_with_data",
|
||||
"energy_performance_report_summary_information",
|
||||
"lodgement_xml_needed_for_lodgement_to_like_trademark",
|
||||
"reduce_xml_needed_to_generate_full_sap_xml",
|
||||
"full_xml_needed_for_co_ordination",
|
||||
// "floor_plan",
|
||||
// "occupancy_assessment",
|
||||
"decent_homes_summary",
|
||||
"decent_homes_property_meta",
|
||||
// "decent_homes_energy_performance_report",
|
||||
// "decent_homes_energy_performance_report_summary_information",
|
||||
// "floor_plan",
|
||||
// "occupancy_assessment",
|
||||
] as const;
|
||||
|
||||
export type ReportType = (typeof REPORT_TYPES)[number];
|
||||
|
||||
// Map reportType → title for UI
|
||||
export const documentTypeTitles: Record<ReportType, string> = {
|
||||
// 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<ReportType, ".pdf" | ".xml" | ".xml,.pdf"> = {
|
||||
// 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<ReportType, string> = {
|
||||
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<string, ReportType> = {
|
||||
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",
|
||||
};
|
||||
DECENT_HOMES_SUMMARY: "decent_homes_summary",
|
||||
DECENT_HOMES_PROPERTY_META: "decent_homes_property_meta",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -9,16 +9,20 @@ import {
|
|||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/app/shadcn_components/ui/drawer";
|
||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
||||
|
||||
// Types
|
||||
interface PropertySummary {
|
||||
id: bigint;
|
||||
uprn: bigint;
|
||||
address: string;
|
||||
postcode: string;
|
||||
has_condition_data: boolean;
|
||||
passes_decent_homes: boolean;
|
||||
category1: Record<string, boolean>;
|
||||
category2: Record<string, boolean>;
|
||||
decent_homes: "pass" | "fail" | "no_data";
|
||||
criterion_a: "pass" | "fail" | "no_data";
|
||||
criterion_b: "pass" | "fail" | "no_data";
|
||||
criterion_c: "pass" | "fail" | "no_data";
|
||||
criterion_d: "pass" | "fail" | "no_data";
|
||||
replacements?: { sub_variable: string; expiry_date: string | null }[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -26,49 +30,170 @@ interface Props {
|
|||
portfolioId: string;
|
||||
}
|
||||
|
||||
type ReasonKey =
|
||||
| "damp_passes"
|
||||
| "structure_passes"
|
||||
| "heating_passes"
|
||||
| "sap_passes"
|
||||
| "thermal_comfort_passes"
|
||||
| "defects_pass"
|
||||
| "windows_pass"
|
||||
| "kitchen_pass"
|
||||
| "bathroom_pass"
|
||||
| "space_pass";
|
||||
// --- Urgency Badge for notifications ---
|
||||
function UrgencyBadge({ label }: { label: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
Overdue: "bg-red-600",
|
||||
"0–6 months": "bg-orange-500",
|
||||
"6–12 months": "bg-yellow-500",
|
||||
">12 months": "bg-green-600",
|
||||
};
|
||||
return (
|
||||
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const reasonLabelMap: Record<ReasonKey, string> = {
|
||||
damp_passes: "Damp",
|
||||
structure_passes: "Structural Issues",
|
||||
heating_passes: "Heating",
|
||||
sap_passes: "SAP Rating",
|
||||
thermal_comfort_passes: "Thermal Comfort",
|
||||
defects_pass: "General Disrepair",
|
||||
windows_pass: "Windows",
|
||||
kitchen_pass: "Kitchen",
|
||||
bathroom_pass: "Bathroom",
|
||||
space_pass: "Space Standard",
|
||||
};
|
||||
function NotificationsPanel({
|
||||
data,
|
||||
portfolioId,
|
||||
}: {
|
||||
data: PropertySummary[];
|
||||
portfolioId: string;
|
||||
}) {
|
||||
const today = new Date();
|
||||
const groups: Record<
|
||||
string,
|
||||
{
|
||||
property: PropertySummary;
|
||||
sub_variable: string;
|
||||
expiry: Date;
|
||||
remaining: string;
|
||||
}[]
|
||||
> = {
|
||||
Overdue: [],
|
||||
"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 (
|
||||
<div className="mt-10">
|
||||
<h2 className="text-lg font-semibold mb-4 text-brandbrown">
|
||||
Upcoming Replacements
|
||||
</h2>
|
||||
<Card className="max-h-[600px] overflow-y-auto">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
{groupOrder.map((urgency) =>
|
||||
groups[urgency].length > 0 ? (
|
||||
<div key={urgency}>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<UrgencyBadge label={urgency} />
|
||||
<span className="text-gray-700 font-medium">
|
||||
{groups[urgency].length} item
|
||||
{groups[urgency].length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
|
||||
{groups[urgency].map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-4 bg-gray-50 rounded-md border hover:bg-gray-100 transition cursor-pointer flex flex-col justify-between"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/portfolio/${portfolioId}/building-passport/${item.property.id}/decent-homes`,
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">
|
||||
{item.sub_variable}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.property.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-sm text-gray-600">
|
||||
<span>{item.remaining}</span>
|
||||
<span>
|
||||
Expiry Date: {item.expiry.toLocaleDateString("en-GB")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<PropertySummary[]>([]);
|
||||
const [selectedTitle, setSelectedTitle] = useState("");
|
||||
|
||||
const total = data.length;
|
||||
const passed = data.filter((p) => p.passes_decent_homes);
|
||||
const failedC1 = data.filter(
|
||||
(p) =>
|
||||
p.has_condition_data &&
|
||||
Object.values(p.category1).some((v) => v === false)
|
||||
);
|
||||
const failedC2 = data.filter(
|
||||
(p) =>
|
||||
p.has_condition_data &&
|
||||
Object.values(p.category2).some((v) => v === false)
|
||||
);
|
||||
const missing = data.filter((p) => !p.has_condition_data);
|
||||
const passes = data.filter((p) => p.decent_homes === "pass");
|
||||
const noData = data.filter((p) => p.decent_homes === "no_data");
|
||||
const critAFails = data.filter((p) => p.criterion_a === "fail");
|
||||
const critBFails = data.filter((p) => p.criterion_b === "fail");
|
||||
const critCFails = data.filter((p) => p.criterion_c === "fail");
|
||||
const critDFails = data.filter((p) => p.criterion_d === "fail");
|
||||
|
||||
const overallPassRate = ((passes.length / total) * 100).toFixed(1);
|
||||
|
||||
const openDrawer = (group: PropertySummary[], title: string) => {
|
||||
setSelectedGroup(group);
|
||||
|
|
@ -76,149 +201,158 @@ const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
|
|||
setOpen(true);
|
||||
};
|
||||
|
||||
const failedReasons = new Map<string, number>();
|
||||
|
||||
data.forEach((p) => {
|
||||
if (!p.has_condition_data || p.passes_decent_homes) return;
|
||||
|
||||
const reasons = Object.entries({ ...p.category1, ...p.category2 })
|
||||
.filter(([_, v]) => v === false)
|
||||
.map(([key]) => key);
|
||||
|
||||
reasons.forEach((r) => {
|
||||
failedReasons.set(r, (failedReasons.get(r) ?? 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
const sortedReasons = Array.from(failedReasons.entries()).sort(
|
||||
(a, b) => b[1] - a[1]
|
||||
);
|
||||
const top3Reasons = sortedReasons.slice(0, 3);
|
||||
const dataCompleteness = (
|
||||
(data.filter((p) => p.has_condition_data).length / total) *
|
||||
100
|
||||
).toFixed(1);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<h1 className="text-2xl font-semibold mb-6">Decent Homes Summary</h1>
|
||||
<div className="sm:col-span-2 rounded-xl bg-brandblue p-[3px] mb-6">
|
||||
<div className="rounded-lg bg-brandbrown p-[3px]">
|
||||
<div className="rounded-md bg-brandblue">
|
||||
<CardContent className="relative p-8 flex flex-col items-center justify-center text-center space-y-3">
|
||||
<div className="text-sm uppercase tracking-wider font-semibold text-brandbrown">
|
||||
Overall Pass Rate
|
||||
</div>
|
||||
<div className="text-6xl font-extrabold text-white drop-shadow">
|
||||
{((passed.length / total) * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-md font-medium text-white/80">
|
||||
<span className="text-brandbrown font-semibold">
|
||||
{passed.length}
|
||||
</span>{" "}
|
||||
out of{" "}
|
||||
<span className="text-brandbrown font-semibold">{total}</span>{" "}
|
||||
homes meet the Decent Homes Standard
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
Decent Homes Portfolio Summary
|
||||
</h1>
|
||||
|
||||
<p className="absolute bottom-4 right-4 text-white/50 italic">
|
||||
Data completeness: {dataCompleteness}%
|
||||
</p>
|
||||
</CardContent>
|
||||
{/* 8 compact cards in a 2x4 grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* Overall Pass Rate */}
|
||||
<div className="rounded-xl bg-brandblue p-[3px]">
|
||||
<div className="rounded-lg bg-brandbrown p-[3px]">
|
||||
<div className="rounded-md bg-brandblue h-full">
|
||||
<CardContent className="p-4 text-center flex flex-col items-center justify-center space-y-2 h-full">
|
||||
<div className="text-sm uppercase tracking-wider font-semibold text-brandbrown">
|
||||
Overall Pass Rate
|
||||
</div>
|
||||
<div className="text-3xl font-extrabold text-white drop-shadow">
|
||||
{overallPassRate}%
|
||||
</div>
|
||||
<div className="text-sm font-medium text-white/80">
|
||||
<span className="text-brandbrown font-semibold">
|
||||
{passes.length}
|
||||
</span>{" "}
|
||||
out of{" "}
|
||||
<span className="text-brandbrown font-semibold">{total}</span>{" "}
|
||||
properties
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Criterion A */}
|
||||
<Card
|
||||
onClick={() =>
|
||||
openDrawer(failedC1, "Properties with Category 1 Risks")
|
||||
}
|
||||
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10"
|
||||
onClick={() => openDrawer(critAFails, "Criterion A Fails")}
|
||||
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
Category 1 Risks
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">
|
||||
Criterion A Fails
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{failedC1.length}
|
||||
<div className="text-3xl font-bold text-brandbrown">
|
||||
{critAFails.length}
|
||||
</div>
|
||||
<div className="text-sm text-brandbrown/80">
|
||||
properties affected
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Criterion B */}
|
||||
<Card
|
||||
onClick={() =>
|
||||
openDrawer(failedC2, "Properties with Category 2 Risks")
|
||||
}
|
||||
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10"
|
||||
onClick={() => openDrawer(critBFails, "Criterion B Fails")}
|
||||
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
Category 2 Risks
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">
|
||||
Criterion B Fails
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{failedC2.length}
|
||||
<div className="text-3xl font-bold text-brandbrown">
|
||||
{critBFails.length}
|
||||
</div>
|
||||
<div className="text-sm text-brandbrown/80">
|
||||
properties affected
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Criterion C */}
|
||||
<Card
|
||||
onClick={() =>
|
||||
openDrawer(missing, "Properties Missing Condition Data")
|
||||
}
|
||||
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10"
|
||||
onClick={() => openDrawer(critCFails, "Criterion C Fails")}
|
||||
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
No Condition Data
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">
|
||||
Criterion C Fails
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{missing.length}
|
||||
<div className="text-3xl font-bold text-brandbrown">
|
||||
{critCFails.length}
|
||||
</div>
|
||||
<div className="text-sm text-brandbrown/80">
|
||||
properties affected
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Criterion D */}
|
||||
<Card
|
||||
onClick={() => openDrawer(passed, "Properties Passing Decent Homes")}
|
||||
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out"
|
||||
onClick={() => openDrawer(critDFails, "Criterion D Fails")}
|
||||
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
|
||||
>
|
||||
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
|
||||
<div className="text-brandbrown font-semibold mb-2">
|
||||
Passes Decent Homes
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">
|
||||
Criterion D Fails
|
||||
</div>
|
||||
<div className="text-5xl font-bold text-white text-center">
|
||||
{passed.length}
|
||||
<div className="text-3xl font-bold text-brandbrown">
|
||||
{critDFails.length}
|
||||
</div>
|
||||
<div className="text-sm text-brandbrown/80">
|
||||
properties affected
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Survey information missing */}
|
||||
<Card
|
||||
onClick={() => openDrawer(noData, "Survey information missing")}
|
||||
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
|
||||
>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">
|
||||
Survey information missing
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-brandbrown">
|
||||
{noData.length}
|
||||
</div>
|
||||
<div className="text-sm text-brandbrown/80">
|
||||
properties affected
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Passes */}
|
||||
<Card
|
||||
onClick={() => openDrawer(passes, "Passes Decent Homes")}
|
||||
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
|
||||
>
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">Passes</div>
|
||||
<div className="text-3xl font-bold text-brandbrown">
|
||||
{passes.length}
|
||||
</div>
|
||||
<div className="text-sm text-brandbrown/80">properties</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Total Properties */}
|
||||
<Card className="transition">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-brandbrown font-semibold">
|
||||
Total Properties
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-brandbrown">{total}</div>
|
||||
<div className="text-sm text-brandbrown/80">portfolio size</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold mb-4 text-brandbrown">
|
||||
Top Reasons for Failing
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{top3Reasons.map(([reason, count]) => (
|
||||
<div
|
||||
key={reason}
|
||||
className="bg-brandblue/10 border border-brandblue/20 rounded-lg p-6 shadow-sm flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="text-red-500 text-2xl mb-2">🚫</div>
|
||||
<h3 className="text-md font-semibold text-brandbrown mb-1">
|
||||
{reasonLabelMap[reason as ReasonKey] ??
|
||||
reason.replace(/_/g, " ")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Failing in <span className="font-medium">{count}</span>{" "}
|
||||
properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Notifications */}
|
||||
<NotificationsPanel data={data} portfolioId={portfolioId} />
|
||||
|
||||
{/* Drawer */}
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent className="p-4 w-full max-w-md ml-auto border-l h-full overflow-y-auto">
|
||||
<DrawerHeader>
|
||||
|
|
@ -233,30 +367,13 @@ const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
|
|||
<div className="text-sm text-muted-foreground mb-2">
|
||||
UPRN: {String(p.uprn)}
|
||||
</div>
|
||||
{p.has_condition_data ? (
|
||||
<>
|
||||
{Object.entries({ ...p.category1, ...p.category2 })
|
||||
.filter(([_, value]) => value === false)
|
||||
.map(([key]) => (
|
||||
<div key={key} className="text-red-500 text-sm">
|
||||
Issue:{" "}
|
||||
{reasonLabelMap[key as ReasonKey] ??
|
||||
key.replace(/_/g, " ")}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-orange-500 text-sm">
|
||||
No condition data available
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 bg-brandblue text-white hover:bg-hoverblue hover:text-gray-100"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`/portfolio/${portfolioId}/building-passport/${p.id}/assessment`,
|
||||
`/portfolio/${portfolioId}/building-passport/${p.id}/decent-homes`,
|
||||
"_blank"
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,21 +43,32 @@ async function getPropertiesWithUprn(
|
|||
|
||||
async function getDocumentsForUprns(
|
||||
uprns: (string | number)[]
|
||||
): Promise<Map<string, typeof uploadedFiles.$inferSelect>> {
|
||||
): Promise<Map<string, Map<string, typeof uploadedFiles.$inferSelect>>> {
|
||||
const rows = await surveyDB.query.uploadedFiles.findMany({
|
||||
where: and(
|
||||
inArray(uploadedFiles.uprn, uprns.map(String)),
|
||||
eq(uploadedFiles.docType, "ECO_CONDITION_REPORT")
|
||||
inArray(uploadedFiles.docType, [
|
||||
"DECENT_HOMES_SUMMARY",
|
||||
"DECENT_HOMES_PROPERTY_META",
|
||||
])
|
||||
),
|
||||
orderBy: (uploadedFiles, { desc }) => [
|
||||
desc(uploadedFiles.s3JsonUploadTimestamp),
|
||||
],
|
||||
});
|
||||
|
||||
const latestByUprn = new Map<string, (typeof rows)[number]>();
|
||||
// Map<UPRN, Map<docType, row>>
|
||||
const latestByUprn = new Map<string, Map<string, (typeof rows)[number]>>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!latestByUprn.has(row.uprn)) {
|
||||
latestByUprn.set(row.uprn, row);
|
||||
latestByUprn.set(row.uprn, new Map());
|
||||
}
|
||||
const docMap = latestByUprn.get(row.uprn)!;
|
||||
|
||||
// if this docType isn’t already set, use it (rows are ordered newest first)
|
||||
if (!docMap.has(row.docType)) {
|
||||
docMap.set(row.docType, row);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,104 +91,52 @@ export default async function DecentHomesPage({
|
|||
|
||||
const summaryResults = await Promise.all(
|
||||
properties.map(async (property) => {
|
||||
const result: any = {
|
||||
const propertyDocs = documentsMap.get(String(property.uprn));
|
||||
const propertyDecentHomes = propertyDocs?.get("DECENT_HOMES_SUMMARY");
|
||||
const propertyDecentHomesMeta = propertyDocs?.get(
|
||||
"DECENT_HOMES_PROPERTY_META"
|
||||
);
|
||||
|
||||
if (
|
||||
!propertyDecentHomes?.s3JsonUri ||
|
||||
!propertyDecentHomesMeta?.s3JsonUri
|
||||
) {
|
||||
return {
|
||||
id: property.id,
|
||||
uprn: property.uprn,
|
||||
address: property.address,
|
||||
postcode: property.postcode,
|
||||
decent_homes: "no_data",
|
||||
criterion_a: "no_data",
|
||||
criterion_b: "no_data",
|
||||
criterion_c: "no_data",
|
||||
criterion_d: "no_data",
|
||||
replacements: [],
|
||||
};
|
||||
}
|
||||
|
||||
const decentHomes = await getEnergyAssessmentFromS3(
|
||||
propertyDecentHomes.s3JsonUri
|
||||
);
|
||||
const decentHomesMeta = await getEnergyAssessmentFromS3(
|
||||
propertyDecentHomesMeta.s3JsonUri
|
||||
);
|
||||
|
||||
return {
|
||||
id: property.id,
|
||||
uprn: property.uprn,
|
||||
address: property.address,
|
||||
postcode: property.postcode,
|
||||
has_condition_data: false,
|
||||
passes_decent_homes: false,
|
||||
category1: {},
|
||||
category2: {},
|
||||
decent_homes: decentHomes.decent_homes,
|
||||
criterion_a: decentHomes.criterion_a,
|
||||
criterion_b: decentHomes.criterion_b,
|
||||
criterion_c: decentHomes.criterion_c,
|
||||
criterion_d: decentHomes.criterion_d,
|
||||
replacements: decentHomesMeta.map((m: any) => ({
|
||||
sub_variable: m.sub_variable,
|
||||
expiry_date: m.expiry_date ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
const propertyMeta = await getPropertyMeta(property.id);
|
||||
|
||||
const conditionReportMeta = documentsMap.get(String(propertyMeta.uprn));
|
||||
|
||||
if (!conditionReportMeta || !conditionReportMeta.s3JsonUri) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.has_condition_data = true;
|
||||
|
||||
const conditionReport = await getEnergyAssessmentFromS3(
|
||||
conditionReportMeta.s3JsonUri
|
||||
);
|
||||
const conditionData = await getConditionReport(String(property.id));
|
||||
|
||||
if (!conditionReport || !conditionData) return result;
|
||||
|
||||
const allRoomData = getAllRoomData(conditionReport.rooms);
|
||||
const roomsWithDamp = getRoomsWithDamp(allRoomData);
|
||||
const roomsWithDefects = getRoomsWithDefects(allRoomData);
|
||||
const roomsWithBadWindows = getRoomsWithBadWindows(allRoomData);
|
||||
const windowsOk = areAllWindowsOk(allRoomData);
|
||||
const elevationsWithIssues = getElevationsWithIssues(conditionReport);
|
||||
|
||||
const {
|
||||
isSufficient: enoughSpace,
|
||||
occupantsToUse,
|
||||
numberOfBedrooms,
|
||||
areaPerPerson,
|
||||
} = hasSufficientSpace(
|
||||
conditionReport.occupant_info,
|
||||
conditionData.totalFloorArea ?? 0,
|
||||
allRoomData
|
||||
);
|
||||
|
||||
const sapOk = meetsSapThreshold(propertyMeta.currentSapPoints ?? 0);
|
||||
const efficientHeating = hasEfficientHeatingSystem(
|
||||
conditionData.heatingRating
|
||||
);
|
||||
const insulationOk = isInsulationAdequate(
|
||||
conditionData.roofRating,
|
||||
conditionData.wallsRating
|
||||
);
|
||||
const thermalComfortOk = efficientHeating && insulationOk;
|
||||
|
||||
const kitchenOk =
|
||||
conditionReport.rooms.kitchen?.room_info
|
||||
?.overall_condition_of_the_room === "Good";
|
||||
const bathroomsOk =
|
||||
Array.isArray(conditionReport.rooms.bathrooms) &&
|
||||
conditionReport.rooms.bathrooms.length > 0 &&
|
||||
conditionReport.rooms.bathrooms.every(
|
||||
(b: any) => b?.room_info?.overall_condition_of_the_room === "Good"
|
||||
);
|
||||
|
||||
const hasDefects = roomsWithDefects.length > 0;
|
||||
|
||||
result.category1 = {
|
||||
damp_passes: roomsWithDamp.length === 0,
|
||||
structure_passes: elevationsWithIssues.length === 0,
|
||||
heating_passes: efficientHeating,
|
||||
sap_passes: sapOk,
|
||||
thermal_comfort_passes: thermalComfortOk,
|
||||
};
|
||||
|
||||
result.category2 = {
|
||||
defects_pass: !hasDefects,
|
||||
windows_pass: windowsOk,
|
||||
kitchen_pass: kitchenOk,
|
||||
bathroom_pass: bathroomsOk,
|
||||
space_pass: enoughSpace,
|
||||
};
|
||||
|
||||
// Overall result
|
||||
result.passes_decent_homes =
|
||||
result.category1.damp_passes &&
|
||||
result.category1.structure_passes &&
|
||||
result.category1.heating_passes &&
|
||||
result.category1.sap_passes &&
|
||||
result.category1.thermal_comfort_passes &&
|
||||
result.category2.defects_pass &&
|
||||
result.category2.windows_pass &&
|
||||
result.category2.kitchen_pass &&
|
||||
result.category2.bathroom_pass &&
|
||||
result.category2.space_pass;
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{Object.keys(conditionReportMeta).length > 0 && (
|
||||
<ConditionReport
|
||||
conditionReport={conditionReport}
|
||||
totalFloorArea={conditionReportData.totalFloorArea}
|
||||
currentSapPoints={propertyMeta.currentSapPoints}
|
||||
conditionData={conditionReportData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nonIntrusiveSurvey && (
|
||||
<div>
|
||||
<div className="flex py-8 text-lg">Non-Intrusive Survey</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,548 @@
|
|||
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,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Hourglass,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
const DISPLAY_NAMES: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
// 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<string, string> = {
|
||||
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 (
|
||||
<Badge className={`${colors} text-white text-xs px-2 py-1`}>
|
||||
{LABEL_MAP[status]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// urgency badge
|
||||
function UrgencyBadge({ label }: { label: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
Overdue: "bg-red-700",
|
||||
"0–6 months": "bg-orange-500",
|
||||
"6–12 months": "bg-yellow-500",
|
||||
">12 months": "bg-green-600",
|
||||
};
|
||||
return (
|
||||
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="h-96 flex flex-col relative overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-brandbrown">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-scroll pr-2 scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100">
|
||||
<ul className="space-y-2 pb-6">
|
||||
{sortedItems.map((item, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex justify-between items-center border-b last:border-0 pb-1"
|
||||
>
|
||||
<span className="text-gray-700">
|
||||
{DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
|
||||
</span>
|
||||
<StatusBadge status={item.result} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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: boolean;
|
||||
}[]
|
||||
> = {
|
||||
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(Math.abs(diffMonths) / 12);
|
||||
const months = Math.floor(Math.abs(diffMonths) % 12);
|
||||
|
||||
let remaining = "";
|
||||
let overdue = false;
|
||||
if (diffMs < 0) {
|
||||
overdue = true;
|
||||
remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`;
|
||||
} else {
|
||||
remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
sub_variable: SUB_ITEMS_TEXT[item.sub_variable] ?? item.sub_variable,
|
||||
expiry,
|
||||
remaining,
|
||||
overdue,
|
||||
};
|
||||
|
||||
if (diffMs < 0) groups.Overdue.push(entry);
|
||||
else if (diffMonths <= 6) groups["0–6 months"].push(entry);
|
||||
else if (diffMonths <= 12) groups["6–12 months"].push(entry);
|
||||
else groups[">12 months"].push(entry);
|
||||
});
|
||||
|
||||
// sort within each group
|
||||
Object.values(groups).forEach((comps) =>
|
||||
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime())
|
||||
);
|
||||
|
||||
const groupOrder: (keyof typeof groups)[] = [
|
||||
"Overdue",
|
||||
"0–6 months",
|
||||
"6–12 months",
|
||||
">12 months",
|
||||
];
|
||||
|
||||
// urgency → card highlight color + icon
|
||||
const cardStyles: Record<string, { border: string; icon: JSX.Element }> = {
|
||||
Overdue: {
|
||||
border: "border-l-4 border-red-600",
|
||||
icon: <AlertTriangle className="w-4 h-4 text-red-600" />,
|
||||
},
|
||||
"0–6 months": {
|
||||
border: "border-l-4 border-orange-500",
|
||||
icon: <Clock className="w-4 h-4 text-orange-500" />,
|
||||
},
|
||||
"6–12 months": {
|
||||
border: "border-l-4 border-yellow-500",
|
||||
icon: <Hourglass className="w-4 h-4 text-yellow-500" />,
|
||||
},
|
||||
">12 months": {
|
||||
border: "border-l-4 border-green-600",
|
||||
icon: <CheckCircle className="w-4 h-4 text-green-600" />,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-[32rem] flex flex-col relative overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium text-brandbrown">
|
||||
Upcoming Replacements
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-scroll pr-2 scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100">
|
||||
{groupOrder.map((urgency) =>
|
||||
groups[urgency].length > 0 ? (
|
||||
<div key={urgency} className="mb-6">
|
||||
{/* group header */}
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<UrgencyBadge label={urgency} />
|
||||
<span className="text-gray-700 font-medium">
|
||||
{groups[urgency].length}{" "}
|
||||
{groups[urgency].length > 1 ? "items" : "item"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{groups[urgency].map((comp, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-4 py-2 bg-gray-50 rounded-md border hover:bg-gray-100 transition ${cardStyles[urgency].border}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-gray-800 flex items-center gap-2">
|
||||
{cardStyles[urgency].icon}
|
||||
{comp.sub_variable}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 flex justify-between">
|
||||
<span>{comp.remaining}</span>
|
||||
<span className={comp.overdue ? "text-red-600" : ""}>
|
||||
{comp.expiry.toLocaleDateString("en-GB")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</CardContent>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-gray-100 to-transparent pointer-events-none" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center mt-10 space-y-6">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-semibold">
|
||||
Decent Homes Assessment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-1">
|
||||
<Badge
|
||||
className={`px-4 py-2 text-lg ${
|
||||
overallPass
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-red-700 hover:bg-red-800"
|
||||
} text-white`}
|
||||
>
|
||||
{overallPass ? "Pass" : "Fail"}
|
||||
</Badge>
|
||||
<p className="text-sm text-gray-500">Last updated: {lastUpdated}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="A" className="w-full max-w-4xl">
|
||||
<TabsList className="grid grid-cols-5 w-full">
|
||||
<TabsTrigger value="A">Criterion A</TabsTrigger>
|
||||
<TabsTrigger value="B">Criterion B</TabsTrigger>
|
||||
<TabsTrigger value="C">Criterion C</TabsTrigger>
|
||||
<TabsTrigger value="D">Criterion D</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="replacements"
|
||||
className="relative flex items-center space-x-2 text-orange-700 font-medium
|
||||
data-[state=active]:bg-brandbrown data-[state=active]:rounded-md data-[state=active]:text-gray-100"
|
||||
>
|
||||
<Wrench className="w-4 h-4" />
|
||||
<span>Replacements</span>
|
||||
{soonCount > 0 && (
|
||||
<span className="absolute -top-1 -right-2 bg-red-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{soonCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="A" className="mt-4">
|
||||
<CriterionContent
|
||||
title="Criterion A: The home meets the current statutory minimum standard for housing"
|
||||
items={criteriaGroups["A"]}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="B" className="mt-4">
|
||||
<CriterionContent
|
||||
title="Criterion B: The home is in a reasonable state of repair"
|
||||
items={criteriaGroups["B"]}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="C" className="mt-4">
|
||||
<CriterionContent
|
||||
title="Criterion C: The home has reasonable modern facilities and services"
|
||||
items={criteriaGroups["C"]}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="D" className="mt-4">
|
||||
<CriterionContent
|
||||
title="Criterion D: The home provides a reasonable degree of thermal comfort"
|
||||
items={criteriaGroups["D"]}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="replacements" className="mt-4">
|
||||
<ReplacementsContent items={replacements} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<DecentHomesSummary
|
||||
decentHomes={decentHomes}
|
||||
decentHomesMeta={decentHomesMeta}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Props> = ({ uprn, uploadedFilesData }) => {
|
||||
export const DocumentsTable: React.FC<Props> = ({
|
||||
uprn,
|
||||
uploadedFilesData,
|
||||
}) => {
|
||||
const filesByType = React.useMemo(() => {
|
||||
const map: Partial<Record<ReportType, getUploadedFile[]>> = {};
|
||||
|
||||
|
|
@ -26,7 +34,7 @@ export const DocumentsTable: React.FC<Props> = ({ 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<Props> = ({ uprn, uploadedFilesData }) =>
|
|||
return map;
|
||||
}, [uploadedFilesData]);
|
||||
|
||||
console.log("filesByType", filesByType);
|
||||
|
||||
return (
|
||||
<Table className="min-w-full table-fixed divide-y divide-gray-200 shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||
<TableBody className="bg-white divide-y divide-gray-200">
|
||||
{REPORT_TYPES.map((reportType) => {
|
||||
const filesForType = filesByType[reportType] ?? [];
|
||||
console.log("reportType", reportType)
|
||||
console.log("reportType", reportType);
|
||||
return (
|
||||
<React.Fragment key={reportType}>
|
||||
<DocumentSection
|
||||
reportType={reportType}
|
||||
uprn={uprn}
|
||||
files={filesForType} // array of rows
|
||||
files={filesForType} // array of rows
|
||||
/>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="h-3 p-0" />
|
||||
|
|
|
|||
|
|
@ -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<getUploadedFiles> {
|
||||
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 (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
|
|
@ -38,7 +35,7 @@ export default async function DocumentsPage(
|
|||
Core Survey Documents
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<DocumentsTable
|
||||
<DocumentsTable
|
||||
uprn={propertyMeta.uprn.toString()}
|
||||
uploadedFilesData={uploadedFiles}
|
||||
/>
|
||||
|
|
@ -51,4 +48,3 @@ export default async function DocumentsPage(
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
|||
<p className="text-xl text-gray-700">{propertyMeta.postcode}</p>
|
||||
</div>
|
||||
<div className="col-span-12 justify-center bg-gray-50 py-2 rounded-md">
|
||||
<Toolbar propertyId={propertyId} portfolioId={portfolioId} conditionReport={conditionReport}/>
|
||||
<Toolbar
|
||||
propertyId={propertyId}
|
||||
portfolioId={portfolioId}
|
||||
decentHomes={decentHomes}
|
||||
/>
|
||||
</div>
|
||||
{propertyMeta.detailsEpc.estimated && <EstimatedDataNotification />}
|
||||
{children}
|
||||
|
|
|
|||
48
src/app/shadcn_components/ui/scroll-area.tsx
Normal file
48
src/app/shadcn_components/ui/scroll-area.tsx
Normal file
|
|
@ -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<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
55
src/app/shadcn_components/ui/tabs.tsx
Normal file
55
src/app/shadcn_components/ui/tabs.tsx
Normal file
|
|
@ -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<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
Loading…
Add table
Reference in a new issue