Non-Intrusive Survey
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx
new file mode 100644
index 00000000..125cb36f
--- /dev/null
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/decent-homes/page.tsx
@@ -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
= {
+ // Criterion A - HHSRS hazards
+ damp_and_mould_growth: "Damp and Mould Growth",
+ excess_cold: "Excess Cold",
+ excess_heat: "Excess Heat",
+ asbestos_and_mm_fibres: "Asbestos and MM Fibres",
+ biocides: "Biocides",
+ carbon_monoxide: "Carbon Monoxide",
+ lead: "Lead",
+ radiation: "Radiation",
+ uncombusted_fuel_gas: "Uncombusted Fuel Gas",
+ volatile_organic_compounds: "Volatile Organic Compounds",
+ crowding_and_space: "Crowding and Space",
+ entry_by_intruders: "Entry by Intruders",
+ lighting: "Lighting",
+ noise: "Noise",
+ domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse",
+ food_safety: "Food Safety",
+ personal_hygiene_sanitation_and_drainage:
+ "Personal Hygiene, Sanitation, and Drainage",
+ water_supply: "Water Supply",
+ falls_associated_with_baths: "Falls Associated with Baths",
+ falls_on_level_surfaces: "Falls on Level Surfaces",
+ falls_on_stairs_and_steps: "Falls on Stairs and Steps",
+ falls_between_levels: "Falls Between Levels",
+ electrical_hazards: "Electrical Hazards",
+ fire: "Fire",
+ flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials",
+ collision_and_entrapment: "Collision and Entrapment",
+ explosions: "Explosions",
+ ergonomics: "Ergonomics",
+ structural_collapse_and_falling_elements:
+ "Structural Collapse and Falling Elements",
+
+ // Criterion B - Key building components
+ wall_structure: "Wall Structure",
+ lintels: "Lintels",
+ wall_finish: "Wall Finish",
+ roof_structure: "Roof Structure",
+ roof_finish: "Roof Finish",
+ chimneys: "Chimneys",
+ windows: "Windows",
+ external_doors: "External Doors",
+ heating_other: "Other Heating Systems",
+ electrical_systems: "Electrical Systems",
+ kitchen: "Kitchen",
+ bathroom: "Bathroom",
+
+ // Criterion C - Modern facilities
+ kitchen_less_than_20_years_old: "Kitchen Less Than 20 Years Old",
+ kitchen_adequate_space_and_layout: "Kitchen Has Adequate Space and Layout",
+ bathroom_less_than_30_years_old: "Bathroom Less Than 30 Years Old",
+ bathroom_wc_appropriately_located: "Bathroom/WC Appropriately Located",
+ adequate_external_noise_insulation: "Adequate External Noise Insulation",
+
+ // Criterion D - Thermal comfort
+ efficient_heating_system_type: "Efficient Heating System Type",
+ efficient_heating_distribution: "Efficient Heating Distribution",
+ loft_insulation_sufficient: "Loft Insulation Sufficient",
+ wall_insulation_sufficient: "Wall Insulation Sufficient",
+};
+
+const SUB_ITEMS_TEXT: Record = {
+ // Criterion A - Hazards (keep as-is, not replacements)
+ damp_and_mould_growth: "Damp and Mould Growth",
+ excess_cold: "Excess Cold",
+ excess_heat: "Excess Heat",
+ asbestos_and_mm_fibres: "Asbestos and MM Fibres",
+ biocides: "Biocides",
+ carbon_monoxide: "Carbon Monoxide",
+ lead: "Lead",
+ radiation: "Radiation",
+ uncombusted_fuel_gas: "Uncombusted Fuel Gas",
+ volatile_organic_compounds: "Volatile Organic Compounds",
+ crowding_and_space: "Crowding and Space",
+ entry_by_intruders: "Entry by Intruders",
+ lighting: "Lighting",
+ noise: "Noise",
+ domestic_hygiene_pests_and_refuse: "Domestic Hygiene, Pests, and Refuse",
+ food_safety: "Food Safety",
+ personal_hygiene_sanitation_and_drainage:
+ "Personal Hygiene, Sanitation, and Drainage",
+ water_supply: "Water Supply",
+ falls_associated_with_baths: "Falls Associated with Baths",
+ falls_on_level_surfaces: "Falls on Level Surfaces",
+ falls_on_stairs_and_steps: "Falls on Stairs and Steps",
+ falls_between_levels: "Falls Between Levels",
+ electrical_hazards: "Electrical Hazards",
+ fire: "Fire",
+ flames_hot_surfaces_and_materials: "Flames, Hot Surfaces, and Materials",
+ collision_and_entrapment: "Collision and Entrapment",
+ explosions: "Explosions",
+ ergonomics: "Ergonomics",
+ structural_collapse_and_falling_elements:
+ "Structural Collapse and Falling Elements",
+
+ // Criterion B - Key components
+ "Wall Structure in External Area": "Wall Structure Renewal",
+ "Lintels in External Area": "Lintel Renewal",
+ "Wall Finish 1 in External Area": "Wall Finish Renewal",
+ "Brickwork Pointing in External Area": "Brickwork Pointing Renewal",
+ "Roof Structure 1 in External Area": "Roof Structure Renewal",
+ "Fascia / Soffit / Bargeboard in External Area":
+ "Fascia / Soffit / Bargeboard Renewal",
+ "Gutters in External Area": "Gutter Renewal",
+ "Downpipes in External Area": "Downpipe Renewal",
+ "Roof Covering 1 in External Area": "Roof Covering Replacement",
+ "Chimneys in External Area": "Chimney Renewal",
+ "Windows in Property": "Window Replacement",
+ "Windows 1 in External Area": "Window Replacement",
+ "Type and Location of Front Door in Property": "Front Door Replacement",
+ "Back and Side Doors 1 in External Area": "Door Replacement",
+ "Back and Side Doors 2 in External Area": "Door Replacement",
+ "Type of Water Heating in Property": "Water Heating System Replacement",
+ "Electrics Required in Property": "Electrical System Renewal",
+ "Adequacy of Kitchen and Type in Property": "Kitchen Renewal",
+ "Adequacy of Bathroom Location in Property": "Bathroom Renewal",
+
+ // Criterion C - Modern facilities
+ kitchen_less_than_20_years_old: "Kitchen Replacement",
+ kitchen_adequate_space_and_layout: "Kitchen Layout Upgrade",
+ bathroom_less_than_30_years_old: "Bathroom Replacement",
+ bathroom_wc_appropriately_located: "Bathroom/WC Layout Upgrade",
+ adequate_external_noise_insulation: "Noise Insulation Upgrade",
+
+ // Criterion D - Thermal comfort
+ efficient_heating_system_type: "Heating System Upgrade",
+ efficient_heating_distribution: "Heating Distribution Upgrade",
+ loft_insulation_sufficient: "Loft Insulation Upgrade",
+ wall_insulation_sufficient: "Wall Insulation Upgrade",
+};
+
+const LABEL_MAP: Record = {
+ pass: "Pass",
+ fail: "Fail",
+ no_data: "Not Assessed",
+};
+
+function StatusBadge({ status }: { status: string }) {
+ const colors =
+ status === "pass"
+ ? "bg-green-600 hover:bg-green-700"
+ : status === "fail"
+ ? "bg-red-700 hover:bg-red-800"
+ : "bg-gray-500 hover:bg-gray-600";
+
+ return (
+
+ {LABEL_MAP[status]}
+
+ );
+}
+
+// urgency badge
+function UrgencyBadge({ label }: { label: string }) {
+ const colorMap: Record = {
+ Overdue: "bg-red-700",
+ "0–6 months": "bg-orange-500",
+ "6–12 months": "bg-yellow-500",
+ ">12 months": "bg-green-600",
+ };
+ return (
+
+ {label}
+
+ );
+}
+
+function CriterionContent({
+ title,
+ items,
+}: {
+ title: string;
+ items: { sub_variable: string; result: string }[];
+}) {
+ const sortedItems = [...items].sort((a, b) => {
+ const order = { fail: 0, no_data: 1, pass: 2 };
+ return (
+ order[a.result as keyof typeof order] -
+ order[b.result as keyof typeof order]
+ );
+ });
+
+ return (
+
+
+ {title}
+
+
+
+ {sortedItems.map((item, idx) => (
+ -
+
+ {DISPLAY_NAMES[item.sub_variable] ?? item.sub_variable}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function ReplacementsContent({
+ items,
+}: {
+ items: { sub_variable: string; expiry_date: string | null }[];
+}) {
+ const today = new Date();
+ const groups: Record<
+ string,
+ {
+ sub_variable: string;
+ expiry: Date;
+ remaining: string;
+ overdue: 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 = {
+ Overdue: {
+ border: "border-l-4 border-red-600",
+ icon: ,
+ },
+ "0–6 months": {
+ border: "border-l-4 border-orange-500",
+ icon: ,
+ },
+ "6–12 months": {
+ border: "border-l-4 border-yellow-500",
+ icon: ,
+ },
+ ">12 months": {
+ border: "border-l-4 border-green-600",
+ icon: ,
+ },
+ };
+
+ return (
+
+
+
+ Upcoming Replacements
+
+
+
+ {groupOrder.map((urgency) =>
+ groups[urgency].length > 0 ? (
+
+ {/* group header */}
+
+
+
+ {groups[urgency].length}{" "}
+ {groups[urgency].length > 1 ? "items" : "item"}
+
+
+
+ {groups[urgency].map((comp, idx) => (
+
+
+
+ {cardStyles[urgency].icon}
+ {comp.sub_variable}
+
+
+
+ {comp.remaining}
+
+ {comp.expiry.toLocaleDateString("en-GB")}
+
+
+
+ ))}
+
+
+ ) : null
+ )}
+
+
+
+ );
+}
+
+function DecentHomesSummary({
+ decentHomes,
+ decentHomesMeta,
+}: {
+ decentHomes: {
+ uprn: number;
+ creation_date: string;
+ criterion_a: string;
+ criterion_b: string;
+ criterion_c: string;
+ criterion_d: string;
+ decent_homes: string;
+ };
+ decentHomesMeta: {
+ criteria: string;
+ sub_variable: string;
+ result: string;
+ expiry_date?: string | null;
+ }[];
+}) {
+ const overallPass = decentHomes.decent_homes === "pass";
+ const lastUpdated = new Date(decentHomes.creation_date).toLocaleDateString(
+ "en-GB",
+ {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ }
+ );
+
+ const criteriaGroups: Record<
+ string,
+ { sub_variable: string; result: string }[]
+ > = {
+ A: [],
+ B: [],
+ C: [],
+ D: [],
+ };
+
+ const replacements: { sub_variable: string; expiry_date: string | null }[] =
+ [];
+
+ decentHomesMeta.forEach((item) => {
+ if (criteriaGroups[item.criteria]) {
+ criteriaGroups[item.criteria].push({
+ sub_variable: item.sub_variable,
+ result: item.result,
+ });
+ }
+ if (item.expiry_date) {
+ replacements.push({
+ sub_variable: item.sub_variable,
+ expiry_date: item.expiry_date,
+ });
+ }
+ });
+
+ const soonCount = replacements.filter((r) => {
+ if (!r.expiry_date) return false;
+ const expiry = new Date(r.expiry_date);
+ const diffMs = expiry.getTime() - new Date().getTime();
+ return diffMs < 1000 * 60 * 60 * 24 * 365;
+ }).length;
+
+ return (
+
+
+
+
+ Decent Homes Assessment
+
+
+
+
+ {overallPass ? "Pass" : "Fail"}
+
+ Last updated: {lastUpdated}
+
+
+
+
+
+ Criterion A
+ Criterion B
+ Criterion C
+ Criterion D
+
+
+ Replacements
+ {soonCount > 0 && (
+
+ {soonCount}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default async function DecentHomesPage(props: {
+ params: Promise<{ slug: string; propertyId: string }>;
+}) {
+ const params = await props.params;
+ const propertyMeta = await getPropertyMeta(params.propertyId);
+
+ const decentHomesSummary = await getDocument({
+ uprn: String(propertyMeta.uprn),
+ documentType: "DECENT_HOMES_SUMMARY",
+ });
+
+ const decentPropertyMeta = await getDocument({
+ uprn: String(propertyMeta.uprn),
+ documentType: "DECENT_HOMES_PROPERTY_META",
+ });
+
+ if (
+ Object.keys(decentHomesSummary).length === 0 ||
+ Object.keys(decentPropertyMeta).length === 0 ||
+ !decentHomesSummary.s3JsonUri ||
+ !decentPropertyMeta.s3JsonUri
+ ) {
+ throw new Error("Decent Homes data is missing");
+ }
+
+ const decentHomesMeta = await getEnergyAssessmentFromS3(
+ decentPropertyMeta.s3JsonUri
+ );
+ const decentHomes = await getEnergyAssessmentFromS3(
+ decentHomesSummary.s3JsonUri
+ );
+
+ return (
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx
index 53c9cd94..a6956fc9 100644
--- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/DocumentsTable.tsx
@@ -1,11 +1,16 @@
"use client";
import React from "react";
-import { Table, TableBody, TableRow, TableCell } from "@/app/shadcn_components/ui/table";
+import {
+ Table,
+ TableBody,
+ TableRow,
+ TableCell,
+} from "@/app/shadcn_components/ui/table";
import { DocumentSection } from "./DocumentSection";
import {
type ReportType,
REPORT_TYPES,
- dbLabelToReportType, // <-- import the map
+ dbLabelToReportType, // <-- import the map
} from "@/app/db/surveyDB/schema/documents";
import type { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
@@ -14,7 +19,10 @@ type Props = {
uploadedFilesData: getUploadedFile[];
};
-export const DocumentsTable: React.FC = ({ uprn, uploadedFilesData }) => {
+export const DocumentsTable: React.FC = ({
+ uprn,
+ uploadedFilesData,
+}) => {
const filesByType = React.useMemo(() => {
const map: Partial> = {};
@@ -26,7 +34,7 @@ export const DocumentsTable: React.FC = ({ uprn, uploadedFilesData }) =>
}
// newest first within each group
- Object.values(map).forEach(arr =>
+ Object.values(map).forEach((arr) =>
arr!.sort(
(a, b) =>
new Date(b.s3FileUploadTimestamp as any).getTime() -
@@ -37,18 +45,20 @@ export const DocumentsTable: React.FC = ({ uprn, uploadedFilesData }) =>
return map;
}, [uploadedFilesData]);
+ console.log("filesByType", filesByType);
+
return (
{REPORT_TYPES.map((reportType) => {
const filesForType = filesByType[reportType] ?? [];
- console.log("reportType", reportType)
+ console.log("reportType", reportType);
return (
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx
index 1ded30a0..799142af 100644
--- a/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/documents/page.tsx
@@ -5,22 +5,17 @@ import { surveyDB } from "@/app/db/surveyDB/connection";
import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
-
-async function getDocuments(
- uprn: number
-): Promise< getUploadedFiles> {
+async function getDocuments(uprn: number): Promise {
const result = surveyDB.query.uploadedFiles.findMany({
where: eq(uploadedFiles.uprn, String(uprn)),
});
-
+
return result;
}
-export default async function DocumentsPage(
- props: {
- params: Promise<{ slug: string; propertyId: string }>;
- }
-) {
+export default async function DocumentsPage(props: {
+ params: Promise<{ slug: string; propertyId: string }>;
+}) {
const params = await props.params;
// Get the property UPRN
const propertyId = params.propertyId;
@@ -31,6 +26,8 @@ export default async function DocumentsPage(
const propertyMeta = await getPropertyMeta(propertyId);
const uploadedFiles = await getDocuments(propertyMeta.uprn);
+ console.log("Uploaded files:", uploadedFiles);
+
return (
<>
@@ -38,7 +35,7 @@ export default async function DocumentsPage(
Core Survey Documents
-
@@ -51,4 +48,3 @@ export default async function DocumentsPage(
>
);
}
-
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx
index 4a313f1d..5da4b86b 100644
--- a/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/layout.tsx
@@ -12,17 +12,15 @@ function EstimatedDataNotification() {
);
}
-export default async function DashboardLayout(
- props: {
- children: React.ReactNode;
- params: Promise<{ slug: string; propertyId: string }>;
- }
-) {
+export default async function DashboardLayout(props: {
+ children: React.ReactNode;
+ params: Promise<{ slug: string; propertyId: string }>;
+}) {
const params = await props.params;
const {
// will be a page or nested layout
- children
+ children,
} = props;
const propertyId = params.propertyId ?? "";
@@ -32,9 +30,10 @@ export default async function DashboardLayout(
const propertyMeta = await getPropertyMeta(params.propertyId);
// We check if we have an uploaded condition report and if so, we show the condition tab. Otherwise, we
// don't show it
- const conditionReport = await getDocument(
- { uprn: String(propertyMeta.uprn), documentType: "ECO_CONDITION_REPORT" }
- );
+ const decentHomes = await getDocument({
+ uprn: String(propertyMeta.uprn),
+ documentType: "DECENT_HOMES_SUMMARY",
+ });
if (!propertyId && propertyId !== "0") {
throw Error("Invalid propertyId");
@@ -58,7 +57,11 @@ export default async function DashboardLayout(
{propertyMeta.postcode}
-
+
{propertyMeta.detailsEpc.estimated && }
{children}
diff --git a/src/app/shadcn_components/ui/scroll-area.tsx b/src/app/shadcn_components/ui/scroll-area.tsx
new file mode 100644
index 00000000..32cf968d
--- /dev/null
+++ b/src/app/shadcn_components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+
+import { cn } from "@/lib/utils";
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+));
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+));
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
+
+export { ScrollArea, ScrollBar };
diff --git a/src/app/shadcn_components/ui/tabs.tsx b/src/app/shadcn_components/ui/tabs.tsx
new file mode 100644
index 00000000..fb01e0ab
--- /dev/null
+++ b/src/app/shadcn_components/ui/tabs.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import * as React from "react";
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+
+import { cn } from "@/lib/utils";
+
+const Tabs = TabsPrimitive.Root;
+
+const TabsList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };