Merge pull request #88 from Hestia-Homes/main

Implemented new version of decent homes ui
This commit is contained in:
KhalimCK 2025-09-23 23:13:27 +01:00 committed by GitHub
commit 27b1d2abcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1279 additions and 372 deletions

160
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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}

View file

@ -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",
};

View file

@ -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[];

View file

@ -9,16 +9,20 @@ import {
DrawerHeader,
DrawerTitle,
} from "@/app/shadcn_components/ui/drawer";
import { Badge } from "@/app/shadcn_components/ui/badge";
// Types
interface PropertySummary {
id: bigint;
uprn: bigint;
address: string;
postcode: string;
has_condition_data: boolean;
passes_decent_homes: boolean;
category1: Record<string, boolean>;
category2: Record<string, boolean>;
decent_homes: "pass" | "fail" | "no_data";
criterion_a: "pass" | "fail" | "no_data";
criterion_b: "pass" | "fail" | "no_data";
criterion_c: "pass" | "fail" | "no_data";
criterion_d: "pass" | "fail" | "no_data";
replacements?: { sub_variable: string; expiry_date: string | null }[];
}
interface Props {
@ -26,49 +30,170 @@ interface Props {
portfolioId: string;
}
type ReasonKey =
| "damp_passes"
| "structure_passes"
| "heating_passes"
| "sap_passes"
| "thermal_comfort_passes"
| "defects_pass"
| "windows_pass"
| "kitchen_pass"
| "bathroom_pass"
| "space_pass";
// --- Urgency Badge for notifications ---
function UrgencyBadge({ label }: { label: string }) {
const colorMap: Record<string, string> = {
Overdue: "bg-red-600",
"06 months": "bg-orange-500",
"612 months": "bg-yellow-500",
">12 months": "bg-green-600",
};
return (
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
{label}
</Badge>
);
}
const reasonLabelMap: Record<ReasonKey, string> = {
damp_passes: "Damp",
structure_passes: "Structural Issues",
heating_passes: "Heating",
sap_passes: "SAP Rating",
thermal_comfort_passes: "Thermal Comfort",
defects_pass: "General Disrepair",
windows_pass: "Windows",
kitchen_pass: "Kitchen",
bathroom_pass: "Bathroom",
space_pass: "Space Standard",
};
function NotificationsPanel({
data,
portfolioId,
}: {
data: PropertySummary[];
portfolioId: string;
}) {
const today = new Date();
const groups: Record<
string,
{
property: PropertySummary;
sub_variable: string;
expiry: Date;
remaining: string;
}[]
> = {
Overdue: [],
"06 months": [],
"612 months": [],
">12 months": [],
};
data.forEach((p) => {
p.replacements?.forEach((r) => {
if (!r.expiry_date) return;
const expiry = new Date(r.expiry_date);
const diffMs = expiry.getTime() - today.getTime();
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
const years = Math.floor(Math.abs(diffMonths) / 12);
const months = Math.floor(Math.abs(diffMonths) % 12);
let remaining = "";
if (diffMs < 0) {
remaining = `Expired ${years > 0 ? `${years}y ` : ""}${months}m ago`;
} else {
remaining = `${years > 0 ? `${years}y ` : ""}${months}m remaining`;
}
if (diffMs < 0) {
groups.Overdue.push({
property: p,
sub_variable: r.sub_variable,
expiry,
remaining,
});
} else if (diffMonths <= 6) {
groups["06 months"].push({
property: p,
sub_variable: r.sub_variable,
expiry,
remaining,
});
} else if (diffMonths <= 12) {
groups["612 months"].push({
property: p,
sub_variable: r.sub_variable,
expiry,
remaining,
});
} else {
groups[">12 months"].push({
property: p,
sub_variable: r.sub_variable,
expiry,
remaining,
});
}
});
});
const groupOrder: (keyof typeof groups)[] = [
"Overdue",
"06 months",
"612 months",
">12 months",
];
return (
<div className="mt-10">
<h2 className="text-lg font-semibold mb-4 text-brandbrown">
Upcoming Replacements
</h2>
<Card className="max-h-[600px] overflow-y-auto">
<CardContent className="p-4 space-y-2">
{groupOrder.map((urgency) =>
groups[urgency].length > 0 ? (
<div key={urgency}>
<div className="flex items-center space-x-2 mb-3">
<UrgencyBadge label={urgency} />
<span className="text-gray-700 font-medium">
{groups[urgency].length} item
{groups[urgency].length > 1 ? "s" : ""}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
{groups[urgency].map((item, idx) => (
<div
key={idx}
className="p-4 bg-gray-50 rounded-md border hover:bg-gray-100 transition cursor-pointer flex flex-col justify-between"
onClick={() =>
window.open(
`/portfolio/${portfolioId}/building-passport/${item.property.id}/decent-homes`,
"_blank"
)
}
>
<div>
<div className="font-medium text-gray-800">
{item.sub_variable}
</div>
<div className="text-sm text-gray-500">
{item.property.address}
</div>
</div>
<div className="mt-2 flex justify-between text-sm text-gray-600">
<span>{item.remaining}</span>
<span>
Expiry Date: {item.expiry.toLocaleDateString("en-GB")}
</span>
</div>
</div>
))}
</div>
</div>
) : null
)}
</CardContent>
</Card>
</div>
);
}
// --- Dashboard ---
const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
const [open, setOpen] = useState(false);
const [selectedGroup, setSelectedGroup] = useState<PropertySummary[]>([]);
const [selectedTitle, setSelectedTitle] = useState("");
const total = data.length;
const passed = data.filter((p) => p.passes_decent_homes);
const failedC1 = data.filter(
(p) =>
p.has_condition_data &&
Object.values(p.category1).some((v) => v === false)
);
const failedC2 = data.filter(
(p) =>
p.has_condition_data &&
Object.values(p.category2).some((v) => v === false)
);
const missing = data.filter((p) => !p.has_condition_data);
const passes = data.filter((p) => p.decent_homes === "pass");
const noData = data.filter((p) => p.decent_homes === "no_data");
const critAFails = data.filter((p) => p.criterion_a === "fail");
const critBFails = data.filter((p) => p.criterion_b === "fail");
const critCFails = data.filter((p) => p.criterion_c === "fail");
const critDFails = data.filter((p) => p.criterion_d === "fail");
const overallPassRate = ((passes.length / total) * 100).toFixed(1);
const openDrawer = (group: PropertySummary[], title: string) => {
setSelectedGroup(group);
@ -76,149 +201,158 @@ const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
setOpen(true);
};
const failedReasons = new Map<string, number>();
data.forEach((p) => {
if (!p.has_condition_data || p.passes_decent_homes) return;
const reasons = Object.entries({ ...p.category1, ...p.category2 })
.filter(([_, v]) => v === false)
.map(([key]) => key);
reasons.forEach((r) => {
failedReasons.set(r, (failedReasons.get(r) ?? 0) + 1);
});
});
const sortedReasons = Array.from(failedReasons.entries()).sort(
(a, b) => b[1] - a[1]
);
const top3Reasons = sortedReasons.slice(0, 3);
const dataCompleteness = (
(data.filter((p) => p.has_condition_data).length / total) *
100
).toFixed(1);
return (
<div className="container mx-auto px-4 py-6">
<h1 className="text-2xl font-semibold mb-6">Decent Homes Summary</h1>
<div className="sm:col-span-2 rounded-xl bg-brandblue p-[3px] mb-6">
<div className="rounded-lg bg-brandbrown p-[3px]">
<div className="rounded-md bg-brandblue">
<CardContent className="relative p-8 flex flex-col items-center justify-center text-center space-y-3">
<div className="text-sm uppercase tracking-wider font-semibold text-brandbrown">
Overall Pass Rate
</div>
<div className="text-6xl font-extrabold text-white drop-shadow">
{((passed.length / total) * 100).toFixed(1)}%
</div>
<div className="text-md font-medium text-white/80">
<span className="text-brandbrown font-semibold">
{passed.length}
</span>{" "}
out of{" "}
<span className="text-brandbrown font-semibold">{total}</span>{" "}
homes meet the Decent Homes Standard
</div>
<h1 className="text-2xl font-semibold mb-6">
Decent Homes Portfolio Summary
</h1>
<p className="absolute bottom-4 right-4 text-white/50 italic">
Data completeness: {dataCompleteness}%
</p>
</CardContent>
{/* 8 compact cards in a 2x4 grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Overall Pass Rate */}
<div className="rounded-xl bg-brandblue p-[3px]">
<div className="rounded-lg bg-brandbrown p-[3px]">
<div className="rounded-md bg-brandblue h-full">
<CardContent className="p-4 text-center flex flex-col items-center justify-center space-y-2 h-full">
<div className="text-sm uppercase tracking-wider font-semibold text-brandbrown">
Overall Pass Rate
</div>
<div className="text-3xl font-extrabold text-white drop-shadow">
{overallPassRate}%
</div>
<div className="text-sm font-medium text-white/80">
<span className="text-brandbrown font-semibold">
{passes.length}
</span>{" "}
out of{" "}
<span className="text-brandbrown font-semibold">{total}</span>{" "}
properties
</div>
</CardContent>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Criterion A */}
<Card
onClick={() =>
openDrawer(failedC1, "Properties with Category 1 Risks")
}
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10"
onClick={() => openDrawer(critAFails, "Criterion A Fails")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
Category 1 Risks
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion A Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{failedC1.length}
<div className="text-3xl font-bold text-brandbrown">
{critAFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Criterion B */}
<Card
onClick={() =>
openDrawer(failedC2, "Properties with Category 2 Risks")
}
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10"
onClick={() => openDrawer(critBFails, "Criterion B Fails")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
Category 2 Risks
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion B Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{failedC2.length}
<div className="text-3xl font-bold text-brandbrown">
{critBFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Criterion C */}
<Card
onClick={() =>
openDrawer(missing, "Properties Missing Condition Data")
}
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out hover:bg-brandblue/10"
onClick={() => openDrawer(critCFails, "Criterion C Fails")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
No Condition Data
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion C Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{missing.length}
<div className="text-3xl font-bold text-brandbrown">
{critCFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Criterion D */}
<Card
onClick={() => openDrawer(passed, "Properties Passing Decent Homes")}
className="hover:shadow-lg cursor-pointer border border-brandblue/30 bg-brandblue/5 transition duration-150 ease-in-out"
onClick={() => openDrawer(critDFails, "Criterion D Fails")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-6 relative bg-brandblue hover:bg-hoverblue">
<div className="text-brandbrown font-semibold mb-2">
Passes Decent Homes
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Criterion D Fails
</div>
<div className="text-5xl font-bold text-white text-center">
{passed.length}
<div className="text-3xl font-bold text-brandbrown">
{critDFails.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Survey information missing */}
<Card
onClick={() => openDrawer(noData, "Survey information missing")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Survey information missing
</div>
<div className="text-3xl font-bold text-brandbrown">
{noData.length}
</div>
<div className="text-sm text-brandbrown/80">
properties affected
</div>
</CardContent>
</Card>
{/* Passes */}
<Card
onClick={() => openDrawer(passes, "Passes Decent Homes")}
className="cursor-pointer transition hover:bg-hoverblue hover:text-white"
>
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">Passes</div>
<div className="text-3xl font-bold text-brandbrown">
{passes.length}
</div>
<div className="text-sm text-brandbrown/80">properties</div>
</CardContent>
</Card>
{/* Total Properties */}
<Card className="transition">
<CardContent className="p-4 text-center">
<div className="text-brandbrown font-semibold">
Total Properties
</div>
<div className="text-3xl font-bold text-brandbrown">{total}</div>
<div className="text-sm text-brandbrown/80">portfolio size</div>
</CardContent>
</Card>
</div>
<div className="mt-8">
<h2 className="text-lg font-semibold mb-4 text-brandbrown">
Top Reasons for Failing
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{top3Reasons.map(([reason, count]) => (
<div
key={reason}
className="bg-brandblue/10 border border-brandblue/20 rounded-lg p-6 shadow-sm flex flex-col justify-between"
>
<div>
<div className="text-red-500 text-2xl mb-2">🚫</div>
<h3 className="text-md font-semibold text-brandbrown mb-1">
{reasonLabelMap[reason as ReasonKey] ??
reason.replace(/_/g, " ")}
</h3>
<p className="text-sm text-muted-foreground">
Failing in <span className="font-medium">{count}</span>{" "}
properties
</p>
</div>
</div>
))}
</div>
</div>
{/* Notifications */}
<NotificationsPanel data={data} portfolioId={portfolioId} />
{/* Drawer */}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="p-4 w-full max-w-md ml-auto border-l h-full overflow-y-auto">
<DrawerHeader>
@ -233,30 +367,13 @@ const DecentHomesDashboard: React.FC<Props> = ({ data, portfolioId }) => {
<div className="text-sm text-muted-foreground mb-2">
UPRN: {String(p.uprn)}
</div>
{p.has_condition_data ? (
<>
{Object.entries({ ...p.category1, ...p.category2 })
.filter(([_, value]) => value === false)
.map(([key]) => (
<div key={key} className="text-red-500 text-sm">
Issue:{" "}
{reasonLabelMap[key as ReasonKey] ??
key.replace(/_/g, " ")}
</div>
))}
</>
) : (
<div className="text-orange-500 text-sm">
No condition data available
</div>
)}
<Button
variant="outline"
size="sm"
className="mt-3 bg-brandblue text-white hover:bg-hoverblue hover:text-gray-100"
onClick={() =>
window.open(
`/portfolio/${portfolioId}/building-passport/${p.id}/assessment`,
`/portfolio/${portfolioId}/building-passport/${p.id}/decent-homes`,
"_blank"
)
}

View file

@ -43,21 +43,32 @@ async function getPropertiesWithUprn(
async function getDocumentsForUprns(
uprns: (string | number)[]
): Promise<Map<string, typeof uploadedFiles.$inferSelect>> {
): Promise<Map<string, Map<string, typeof uploadedFiles.$inferSelect>>> {
const rows = await surveyDB.query.uploadedFiles.findMany({
where: and(
inArray(uploadedFiles.uprn, uprns.map(String)),
eq(uploadedFiles.docType, "ECO_CONDITION_REPORT")
inArray(uploadedFiles.docType, [
"DECENT_HOMES_SUMMARY",
"DECENT_HOMES_PROPERTY_META",
])
),
orderBy: (uploadedFiles, { desc }) => [
desc(uploadedFiles.s3JsonUploadTimestamp),
],
});
const latestByUprn = new Map<string, (typeof rows)[number]>();
// Map<UPRN, Map<docType, row>>
const latestByUprn = new Map<string, Map<string, (typeof rows)[number]>>();
for (const row of rows) {
if (!latestByUprn.has(row.uprn)) {
latestByUprn.set(row.uprn, row);
latestByUprn.set(row.uprn, new Map());
}
const docMap = latestByUprn.get(row.uprn)!;
// if this docType isnt already set, use it (rows are ordered newest first)
if (!docMap.has(row.docType)) {
docMap.set(row.docType, row);
}
}
@ -80,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;
})
);

View file

@ -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;

View file

@ -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>

View file

@ -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",
"06 months": "bg-orange-500",
"612 months": "bg-yellow-500",
">12 months": "bg-green-600",
};
return (
<Badge className={`${colorMap[label]} text-white text-xs px-2 py-1`}>
{label}
</Badge>
);
}
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: [],
"06 months": [],
"612 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["06 months"].push(entry);
else if (diffMonths <= 12) groups["612 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",
"06 months",
"612 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" />,
},
"06 months": {
border: "border-l-4 border-orange-500",
icon: <Clock className="w-4 h-4 text-orange-500" />,
},
"612 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}
/>
);
}

View file

@ -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" />

View file

@ -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(
</>
);
}

View file

@ -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}

View 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 };

View 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 };