mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
new decent homes ui
This commit is contained in:
parent
e6b0b70270
commit
65ce0ecb2a
5 changed files with 780 additions and 0 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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,515 @@
|
|||
import {
|
||||
getPropertyMeta,
|
||||
getDocument,
|
||||
getEnergyAssessmentFromS3,
|
||||
} from "../utils";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { Badge } from "@/app/shadcn_components/ui/badge";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/app/shadcn_components/ui/tabs";
|
||||
import { Wrench } from "lucide-react";
|
||||
|
||||
const DISPLAY_NAMES: Record<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-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>
|
||||
);
|
||||
}
|
||||
|
||||
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>{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: [],
|
||||
"0–6 months": [],
|
||||
"6–12 months": [],
|
||||
">12 months": [],
|
||||
};
|
||||
|
||||
items.forEach((item) => {
|
||||
if (!item.expiry_date) return;
|
||||
const expiry = new Date(item.expiry_date);
|
||||
const diffMs = expiry.getTime() - today.getTime();
|
||||
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
|
||||
const years = Math.floor(diffMonths / 12);
|
||||
const months = Math.floor(diffMonths % 12);
|
||||
const remaining =
|
||||
diffMs < 0
|
||||
? "Expired"
|
||||
: `${years > 0 ? `${years}y ` : ""}${months}m remaining`;
|
||||
|
||||
if (diffMs < 0) {
|
||||
groups.Overdue.push({
|
||||
sub_variable: SUB_ITEMS_TEXT[item.sub_variable],
|
||||
expiry,
|
||||
remaining,
|
||||
});
|
||||
} else if (diffMonths <= 6) {
|
||||
groups["0–6 months"].push({
|
||||
sub_variable: SUB_ITEMS_TEXT[item.sub_variable],
|
||||
expiry,
|
||||
remaining,
|
||||
});
|
||||
} else if (diffMonths <= 12) {
|
||||
groups["6–12 months"].push({
|
||||
sub_variable: SUB_ITEMS_TEXT[item.sub_variable],
|
||||
expiry,
|
||||
remaining,
|
||||
});
|
||||
} else {
|
||||
groups[">12 months"].push({
|
||||
sub_variable: SUB_ITEMS_TEXT[item.sub_variable],
|
||||
expiry,
|
||||
remaining,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// sort each group by expiry date ascending (soonest first)
|
||||
Object.values(groups).forEach((comps) => {
|
||||
comps.sort((a, b) => a.expiry.getTime() - b.expiry.getTime());
|
||||
});
|
||||
|
||||
// order of groups
|
||||
const groupOrder: (keyof typeof groups)[] = [
|
||||
"Overdue",
|
||||
"0–6 months",
|
||||
"6–12 months",
|
||||
">12 months",
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="h-96 flex flex-col relative overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>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-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<UrgencyBadge label={urgency} />
|
||||
<span className="text-gray-700 font-medium">
|
||||
{groups[urgency].length} items
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1 ml-2">
|
||||
{groups[urgency].map((comp, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex justify-between border-b pb-1 last:border-0"
|
||||
>
|
||||
<span>
|
||||
{DISPLAY_NAMES[comp.sub_variable] ?? comp.sub_variable}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{comp.remaining}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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