Merge pull request #78 from Hestia-Homes/feature/condition

Feature/condition - added fix for showing funding ui when funding not produced
This commit is contained in:
KhalimCK 2025-08-26 15:01:26 +01:00 committed by GitHub
commit 5609e513c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 811 additions and 239 deletions

98
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
@ -2734,6 +2735,43 @@
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@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-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-accordion/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-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@ -2787,6 +2825,66 @@
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.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-collapsible/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-collapsible/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-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View file

@ -18,6 +18,7 @@
"@headlessui/react": "^2.2.7",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",

View file

@ -320,7 +320,7 @@ export default function RecommendationContainer({
console.warn("Multiple funding packages found, using the first one.");
}
const [totalFunding, setTotalFunding] = useState(funding[0].projectFunding)
const [totalFunding, setTotalFunding] = useState(funding[0]?.projectFunding || 0)
const currentEpcRating = propertyMeta.currentEpcRating;
const currentSapPoints = propertyMeta.currentSapPoints;

View file

@ -16,10 +16,12 @@ import {
NavigationMenuLink,
} from "@/app/shadcn_components/ui/navigation-menu";
import { cva } from "class-variance-authority";
import { getUploadedFile } from "@/app/db/surveyDB/schema/surveyDB";
interface ToolbarProps {
propertyId: string;
portfolioId: string;
conditionReport: getUploadedFile;
}
const navigationMenuTriggerStyle = cva(
@ -53,7 +55,7 @@ const navigationMenuTriggerStyle = cva(
].join(" ")
);
export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
export function Toolbar({ propertyId, portfolioId, conditionReport }: ToolbarProps) {
function handleClickSettings() {
console.log("Settings were clicked, implement me");
}
@ -61,7 +63,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
const preAssessmentReportButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/pre-assessment-report`}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/assessment`}
>
<NewspaperIcon className="h-4 w-4 mr-2" />
Data
@ -123,7 +125,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
{solarAnalysisButton}
{recommendationsButton}
{documentsButton}
{energyAssessmentsReportButton}
{/* {energyAssessmentsReportButton} */}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}

View file

@ -105,14 +105,14 @@ export default function ValuationImpactComponent({
</div>
<FundingSummary
scheme={funding.scheme}
scheme={funding?.scheme}
onSeeMore={openFundingModal}
/>
<FundingSummaryModal
isOpen={fundingModalIsOpen}
closeModal={() => setFundingModalIsOpen(false)}
scheme={funding.scheme}
fundingPackageMeasures={funding.fundingPackageMeasures}
scheme={funding?.scheme}
fundingPackageMeasures={funding?.fundingPackageMeasures || []}
/>
</div>
);

View file

@ -12,7 +12,7 @@ export const DB_REPORT_TYPES = [
export const docTypeEnum = pgEnum("reporttype", DB_REPORT_TYPES);
export const uploaded_files = pgTable("uploaded_files", {
export const uploadedFiles = pgTable("uploaded_files", {
id: uuid("id").primaryKey().defaultRandom(),
s3JsonUri: text("s3_json_uri"),
@ -26,6 +26,6 @@ export const uploaded_files = pgTable("uploaded_files", {
uprn: text("uprn").notNull(),
});
export type getUploadedFile = typeof uploaded_files.$inferSelect
export type getUploadedFile = typeof uploadedFiles.$inferSelect
export type getUploadedFiles = getUploadedFile[];

View file

@ -17,8 +17,6 @@ export default async function PortfolioSummary(
// Get user id from the session
const scenarios = await getNonDefaultPortfolioScenarios(portfolioId);
console.log(data)
return (
<div className="container mx-auto px-4">
<h1 className="text-3xl text-gray-700 font-bold my-4">Summary</h1>

View file

@ -0,0 +1,257 @@
"use client";
import {
Card,
CardContent,
CardHeader,
} from "@/app/shadcn_components/ui/card";
import {
CheckCircle,
XCircle,
HelpCircle,
} from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/app/shadcn_components/ui/accordion";
import clsx from "clsx";
function ChecklistItem({
label,
passed,
note,
alert = false,
roomsWithIssues = [],
}: {
label: string;
passed?: boolean;
note?: string;
alert?: boolean;
roomsWithIssues?: [string, any][];
}) {
const icon = passed === true ? (
<CheckCircle className="text-brandbrown w-5 h-5 shrink-0" />
) : passed === false ? (
<XCircle
className={clsx(
"w-5 h-5 shrink-0",
alert ? "text-red-600" : "text-brandblue"
)}
/>
) : (
<HelpCircle className="text-brandblue w-5 h-5 shrink-0" />
);
return (
<div className="bg-muted/50 px-3 py-2 rounded-md space-y-2">
<div className="flex items-center gap-3">
{icon}
<span className="text-sm font-medium text-foreground">
{label}
{note && (
<span className="ml-1 text-xs text-muted-foreground font-normal">
({note})
</span>
)}
</span>
</div>
{roomsWithIssues.length > 0 && (
<Accordion type="multiple" className="ml-6 border-l border-muted pl-4 mt-2">
{roomsWithIssues.map(([roomName, room]: any) => (
<AccordionItem key={roomName} value={roomName}>
<AccordionTrigger>{formatRoomName(roomName)}</AccordionTrigger>
<AccordionContent>
<div className="text-sm text-muted-foreground space-y-1 px-1">
{room.room_info?.overall_condition_of_the_room && (
<div>
<strong>Condition:</strong> {room.room_info.overall_condition_of_the_room}
</div>
)}
{room.room_info?.does_the_room_have_any_defects && (
<div>
<strong>Defects:</strong> {room.room_info.does_the_room_have_any_defects}
</div>
)}
{room.room_info?.ventilation_info
?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room !==
undefined && (
<div>
<strong>Damp/Mould:</strong>{" "}
{room.room_info.ventilation_info
.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room
? "Yes"
: "No"}
</div>
)}
{room.room_info?.windows_info?.condition_of_the_windows && (
<div>
<strong>Window Condition:</strong>{" "}
{room.room_info.windows_info.condition_of_the_windows}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)}
</div>
);
}
function formatRoomName(name: string) {
return name
.replaceAll("_", " ")
.replace(/\b\w/g, (l) => l.toUpperCase())
.replace("Room Info", "Room");
}
function getRecommendedOccupants(bedrooms: number): number {
if (bedrooms <= 0) return 0;
if (bedrooms === 1) return 2;
if (bedrooms === 2) return 4;
if (bedrooms === 3) return 6;
return 7; // 4 or more
}
export default function ConditionReport({
conditionReport,
totalFloorArea
}: {
conditionReport: {
rooms: Record<string, any>;
[key: string]: any;
},
totalFloorArea: number;
}) {
// Documentation on decent home standards can be found here:
// https://assets.publishing.service.gov.uk/media/5a7968b740f0b63d72fc5926/138355.pdf
const rooms = conditionReport.rooms;
const allRoomData = [
...Object.entries(rooms).filter(([k, v]) => v?.room_info),
...(rooms.bedrooms || []).map((b: any, i: number) => ["Bedroom " + (i + 1), b]),
...(rooms.bathrooms || []).map((b: any, i: number) => ["Bathroom " + (i + 1), b]),
];
const hasDampIssues = allRoomData.some(
([, room]: any) =>
room.room_info?.ventilation_info
?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room
);
const hasDefects = allRoomData.some(
([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes"
);
const windowsOk = allRoomData.every(([, room]: any) => {
const wi = room.room_info?.windows_info;
return wi?.does_the_room_have_any_windows
? wi?.condition_of_the_windows === "Good condition"
: true;
});
const heatingWorking =
conditionReport.heating_system?.general_condition
?.is_the_heating_system_in_working_order === true;
const kitchenOk = rooms.kitchen?.room_info?.overall_condition_of_the_room === "Good";
const bathroomsOk = Array.isArray(rooms.bathrooms) &&
rooms.bathrooms.length > 0 &&
rooms.bathrooms.every(
(b: any) =>
b?.room_info?.overall_condition_of_the_room === "Good"
)
const roomsWithDefects = allRoomData.filter(
([, room]: any) => room.room_info?.does_the_room_have_any_defects === "Yes"
);
const roomsWithDamp = allRoomData.filter(
([, room]: any) =>
room.room_info?.ventilation_info
?.are_there_any_visible_or_reported_signs_of_damp_mould_or_excessive_condensation_within_the_room
);
const roomsWithBadWindows = allRoomData.filter(
([, room]: any) => {
const wi = room.room_info?.windows_info;
return wi?.does_the_room_have_any_windows && wi.condition_of_the_windows !== "Good condition";
}
);
// Check if the property has adequate space
// We've seen a case where the number of adult occupants + child occupants is greater than the total_number_of_occupants so we
// take the biggest of the two
const totalOccupants = conditionReport.occupant_info?.total_number_of_occupants ?? 0;
const totalAdults = conditionReport.occupant_info?.no_of_adult_occupants ?? 0;
const totalChildren = conditionReport.occupant_info?.no_of_child_occupants ?? 0;
const numberOfBedrooms = Array.isArray(rooms.bedrooms)
? rooms.bedrooms.length
: 0;
const occupantsToUse = Math.max(totalAdults + totalChildren, totalOccupants);
const maxOccupants = getRecommendedOccupants(numberOfBedrooms);
const areaPerPerson = totalFloorArea / occupantsToUse;
const hasSufficientSpace = (occupantsToUse <= maxOccupants) && (areaPerPerson >= 20);
return (
<div className="space-y-6 mt-8">
<Card>
<CardHeader className="text-lg font-semibold text-brandblue">
Decent Homes Checklist
</CardHeader>
<CardContent className="space-y-3">
<ChecklistItem
label={roomsWithDamp ? "Signs of damp or mould present": "No signs of damp or mould"}
passed={roomsWithDamp.length === 0}
alert={roomsWithDamp.length > 0}
roomsWithIssues={roomsWithDamp}
/>
<ChecklistItem
label={hasDefects ? "Room defects present" : "No room defects present"}
passed={!hasDefects}
alert={hasDefects}
roomsWithIssues={roomsWithDefects}
/>
<ChecklistItem
label={heatingWorking ? "Heating system operational" : "Heating system not operational"}
passed={heatingWorking}
alert={!heatingWorking}
/>
<ChecklistItem
label={windowsOk ? "Windows in good condition" : "Windows not in good condition"}
passed={windowsOk}
alert={!windowsOk}
roomsWithIssues={roomsWithBadWindows}
/>
<ChecklistItem
label={kitchenOk ? "Kitchen in good condition" : "Kitchen not in good condition"}
passed={kitchenOk}
alert={!kitchenOk}
/>
<ChecklistItem
label={bathroomsOk ? "Bathrooms in good condition" : "Bathrooms not in good condition"}
passed={bathroomsOk}
alert={!bathroomsOk}
/>
<ChecklistItem
label="Sufficient space for number of occupants"
passed={hasSufficientSpace}
alert={!hasSufficientSpace}
note={`${totalOccupants} occupants, ${numberOfBedrooms} bedrooms. ${areaPerPerson}m² per person`}
/>
</CardContent>
</Card>
</div>
);
}

View file

@ -20,16 +20,10 @@ import {
getPropertyMeta,
getSpatialData,
getNonIntrusiveSurvey,
getDocument,
getEnergyAssessmentFromS3
} from "../utils";
function AddressCard({ address }: { address: string | null }) {
// In the future, we might want to use react-wrap-balancer for some of this text
return (
<div className="flex flex-col items-center p-4 shadow rounded-md max-w-xl mx-auto justify-start text-gray-100 bg-brandblue">
<div className="text-2xl font-bold max-w-l">{address}</div>
</div>
);
}
import ConditionReport from "@/app/portfolio/[slug]/building-passport/[propertyId]/assessment/ConditionReport";
interface PropertyDetailsCardProps {
conditionReportData: PropertyDetailsEpc;
@ -141,6 +135,15 @@ export default async function PreAssessmentReport(
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.rooms.utility)
const nonIntrusiveSurvey = await getNonIntrusiveSurvey(propertyMeta.uprn);
@ -148,10 +151,15 @@ export default async function PreAssessmentReport(
const heatingDemand = formatHeatDemandFeatures(conditionReportData);
// If total floor area is missing, we have a problem
if (conditionReportData.totalFloorArea == null) {
console.error("Total floor area is missing");
return null;
}
return (
<div className="leading-loose tracking-wider">
<div className="flex py-8 text-lg">Pre Assessment Report</div>
<div className="text-gray-700 text-sm">
<div className="text-gray-700 text-sm mt-4">
Last updated: {formatDateTime(propertyMeta.updatedAt)}
</div>
<div className="flex flex-col items-stretch mb-4">
@ -169,6 +177,10 @@ export default async function PreAssessmentReport(
</div>
</div>
{
Object.keys(conditionReportMeta).length > 0 && <ConditionReport conditionReport={conditionReport} totalFloorArea={conditionReportData.totalFloorArea}/>
}
{nonIntrusiveSurvey && (
<div>
<div className="flex py-8 text-lg">Non-Intrusive Survey</div>

View file

@ -2,16 +2,15 @@ import { getPropertyMeta } from "@/app/portfolio/[slug]/building-passport/[prope
import { eq } from "drizzle-orm";
import { DocumentsTable } from "./DocumentsTable";
import { surveyDB } from "@/app/db/surveyDB/connection";
import { uploaded_files } from "@/app/db/surveyDB/schema/surveyDB";
import { uploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import { type getUploadedFiles } from "@/app/db/surveyDB/schema/surveyDB";
import { EmptyObject } from "react-hook-form";
async function getDocuments(
uprn: number
): Promise< getUploadedFiles> {
const result = surveyDB.query.uploaded_files.findMany({
where: eq(uploaded_files.uprn, String(uprn)),
const result = surveyDB.query.uploadedFiles.findMany({
where: eq(uploadedFiles.uprn, String(uprn)),
});
return result;

View file

@ -1,5 +1,5 @@
import { Toolbar } from "@/app/components/building-passport/Toolbar";
import { getPropertyMeta } from "./utils";
import { getPropertyMeta, getDocument } from "./utils";
import BackToPortfolioButton from "@/app/components/building-passport/BackToPortfolioButton";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
@ -30,6 +30,11 @@ export default async function DashboardLayout(
// The layout is a server component by default so we can fetch meta data here
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" }
);
if (!propertyId && propertyId !== "0") {
throw Error("Invalid propertyId");
@ -53,7 +58,7 @@ 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} />
<Toolbar propertyId={propertyId} portfolioId={portfolioId} conditionReport={conditionReport}/>
</div>
{propertyMeta.detailsEpc.estimated && <EstimatedDataNotification />}
{children}

View file

@ -1,3 +1,4 @@
import S3 from "aws-sdk/clients/s3";
import {
Recommendation,
planRecommendations,
@ -5,6 +6,7 @@ import {
Plan,
} from "@/app/db/schema/recommendations";
import { db } from "@/app/db/db";
import { surveyDB } from "@/app/db/surveyDB/connection";
import {
Feature,
GeneralFeature,
@ -17,7 +19,7 @@ import {
nonInstrusiveSurvey,
} from "@/app/db/schema/property";
import { getRating } from "@/app/utils";
import { eq, desc } from "drizzle-orm";
import { eq, desc, and } from "drizzle-orm";
import {
energyAssessment,
EnergyAssessment,
@ -26,9 +28,38 @@ import {
} from "@/app/db/schema/energy_assessments";
import {
fundingPackage,
FundingPackage,
FundingPackageWithMeasures
} from "@/app/db/schema/funding";
import { getUploadedFile, uploadedFiles, DB_REPORT_TYPES } from "@/app/db/surveyDB/schema/surveyDB";
export async function getEnergyAssessmentFromS3(s3Uri: string): Promise<any> {
const url = new URL(s3Uri);
const bucketMatch = url.hostname.match(/^(.+)\.s3/);
const bucket = bucketMatch?.[1];
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
if (!bucket || !key) {
throw new Error("Could not extract bucket or key from URI");
}
const s3 = new S3({
region: "eu-west-2",
accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY,
secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET,
});
const result = await s3
.getObject({
Bucket: bucket,
Key: key,
})
.promise();
const body = result.Body?.toString("utf-8");
return body ? JSON.parse(body) : null;
}
type RecommendationList = {
recommendation: Recommendation;
@ -49,6 +80,21 @@ export async function getPlanFunding(planId: string): Promise<FundingPackageWith
return data;
}
export async function getDocument(
{uprn, documentType}: {uprn: string; documentType: typeof DB_REPORT_TYPES[number]}
): Promise<getUploadedFile> {
// We get the latest entry for the given UPRN and document type, by s3JsonUploadTimestamp
const data = await surveyDB.query.uploadedFiles.findFirst({
where: and(eq(uploadedFiles.uprn, String(uprn)), eq(uploadedFiles.docType, documentType)),
orderBy: (uploadedFiles, { desc }) => [desc(uploadedFiles.s3JsonUploadTimestamp)]
});
// We may not have an uploaded document so we return an empty array
if (!data) {
return {} as getUploadedFile;
}
return data;
}
export async function getEnergyAssessment(
uprn: number
): Promise<EnergyAssessment> {

View file

@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from '@/lib/utils'
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -12,193 +12,227 @@ module.exports = {
"./node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
theme: {
transparent: "transparent",
current: "currentColor",
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
colors: {
// Tremor light mode
tremor: {
brand: {
faint: colors.blue[50],
muted: colors.blue[200],
subtle: colors.blue[400],
DEFAULT: colors.blue[500],
emphasis: colors.blue[700],
inverted: colors.white,
},
background: {
muted: colors.gray[50],
subtle: colors.gray[100],
DEFAULT: colors.white,
emphasis: colors.gray[700],
},
border: {
DEFAULT: colors.gray[200],
},
ring: {
DEFAULT: colors.gray[200],
},
content: {
subtle: colors.gray[400],
DEFAULT: colors.gray[500],
emphasis: colors.gray[700],
strong: colors.gray[900],
inverted: colors.white,
},
},
// Tremor dark mode
// dark mode
"dark-tremor": {
brand: {
faint: "#0B1229",
muted: colors.blue[950],
subtle: colors.blue[800],
DEFAULT: colors.blue[500],
emphasis: colors.blue[400],
inverted: colors.blue[950],
},
background: {
muted: "#131A2B",
subtle: colors.gray[800],
DEFAULT: colors.gray[900],
emphasis: colors.gray[300],
},
border: {
DEFAULT: colors.gray[800],
},
ring: {
DEFAULT: colors.gray[800],
},
content: {
subtle: colors.gray[600],
DEFAULT: colors.gray[500],
emphasis: colors.gray[200],
strong: colors.gray[50],
inverted: colors.gray[950],
},
},
epc_a: "#117d58",
epc_b: "#2da55c",
epc_c: "#8dbd40",
epc_d: "#f7cd14",
epc_e: "#f3a96a",
epc_f: "#ef8026",
epc_g: "#e41e3b",
brandblue: "#14163d",
hoverblue: "#3e4073",
brandtan: "#d3b488",
hovertan: "#947750",
brandgold: "#f1bb06",
hovergold: "#c79d12",
brandbrown: "#c4a47c",
brandmidblue: "#3943b7",
brandlightblue: "#00a9f4",
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
textColor: {
brandblue: "#14163d",
hoverblue: "#3e4073",
brandtan: "#d3b488",
hovertan: "#947750",
brandbrown: "#c4a47c",
brandmidblue: "#3943b7",
brandlightblue: "#00a9f4",
},
borderRadius: {
lg: `var(--radius)`,
md: `calc(var(--radius) - 2px)`,
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
maxWidth: {
"8xl": "90rem",
},
boxShadow: {
// light
"tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
// dark
"dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"dark-tremor-card":
"0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
"dark-tremor-dropdown":
"0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
borderRadius: {
"tremor-small": "0.375rem",
"tremor-default": "0.5rem",
"tremor-full": "9999px",
},
fontSize: {
"tremor-label": ["0.75rem", { lineHeight: "1rem" }],
"tremor-default": ["0.875rem", { lineHeight: "1.25rem" }],
"tremor-title": ["1.125rem", { lineHeight: "1.75rem" }],
"tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }],
},
},
transparent: 'transparent',
current: 'currentColor',
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
},
colors: {
tremor: {
brand: {
faint: 'colors.blue[50]',
muted: 'colors.blue[200]',
subtle: 'colors.blue[400]',
DEFAULT: 'colors.blue[500]',
emphasis: 'colors.blue[700]',
inverted: 'colors.white'
},
background: {
muted: 'colors.gray[50]',
subtle: 'colors.gray[100]',
DEFAULT: 'colors.white',
emphasis: 'colors.gray[700]'
},
border: {
DEFAULT: 'colors.gray[200]'
},
ring: {
DEFAULT: 'colors.gray[200]'
},
content: {
subtle: 'colors.gray[400]',
DEFAULT: 'colors.gray[500]',
emphasis: 'colors.gray[700]',
strong: 'colors.gray[900]',
inverted: 'colors.white'
}
},
'dark-tremor': {
brand: {
faint: '#0B1229',
muted: 'colors.blue[950]',
subtle: 'colors.blue[800]',
DEFAULT: 'colors.blue[500]',
emphasis: 'colors.blue[400]',
inverted: 'colors.blue[950]'
},
background: {
muted: '#131A2B',
subtle: 'colors.gray[800]',
DEFAULT: 'colors.gray[900]',
emphasis: 'colors.gray[300]'
},
border: {
DEFAULT: 'colors.gray[800]'
},
ring: {
DEFAULT: 'colors.gray[800]'
},
content: {
subtle: 'colors.gray[600]',
DEFAULT: 'colors.gray[500]',
emphasis: 'colors.gray[200]',
strong: 'colors.gray[50]',
inverted: 'colors.gray[950]'
}
},
epc_a: '#117d58',
epc_b: '#2da55c',
epc_c: '#8dbd40',
epc_d: '#f7cd14',
epc_e: '#f3a96a',
epc_f: '#ef8026',
epc_g: '#e41e3b',
brandblue: '#14163d',
hoverblue: '#3e4073',
brandtan: '#d3b488',
hovertan: '#947750',
brandgold: '#f1bb06',
hovergold: '#c79d12',
brandbrown: '#c4a47c',
brandmidblue: '#3943b7',
brandlightblue: '#00a9f4',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
textColor: {
brandblue: '#14163d',
hoverblue: '#3e4073',
brandtan: '#d3b488',
hovertan: '#947750',
brandbrown: '#c4a47c',
brandmidblue: '#3943b7',
brandlightblue: '#00a9f4'
},
borderRadius: {
'tremor-small': '0.375rem',
'tremor-default': '0.5rem',
'tremor-full': '9999px'
},
fontFamily: {
sans: [
'var(--font-sans)',
...fontFamily.sans
]
},
keyframes: {
'accordion-down': {
from: {
height: 0
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: 0
}
},
'accordion-down': {
from: {
height: '0'
},
to: {
height: 'var(--radix-accordion-content-height)'
}
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)'
},
to: {
height: '0'
}
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out'
},
maxWidth: {
'8xl': '90rem'
},
boxShadow: {
'tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'dark-tremor-input': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
'dark-tremor-card': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'dark-tremor-dropdown': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
},
fontSize: {
'tremor-label': [
'0.75rem',
{
lineHeight: '1rem'
}
],
'tremor-default': [
'0.875rem',
{
lineHeight: '1.25rem'
}
],
'tremor-title': [
'1.125rem',
{
lineHeight: '1.75rem'
}
],
'tremor-metric': [
'1.875rem',
{
lineHeight: '2.25rem'
}
]
}
}
},
variants: {
extend: {
@ -234,28 +268,90 @@ module.exports = {
/^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/,
},
// This enables the EPC colours for tremor. They're listed from EPC G -> A
...[
"[#e41e3b]",
"[#ef8026]",
"[#f3a96a]",
"[#f7cd14]",
"[#8dbd40]",
"[#2da55c]",
"[#117d58]",
].flatMap((customColor) => [
`bg-${customColor}`,
`border-${customColor}`,
`hover:bg-${customColor}`,
`hover:border-${customColor}`,
`hover:text-${customColor}`,
`fill-${customColor}`,
`ring-${customColor}`,
`stroke-${customColor}`,
`text-${customColor}`,
`ui-selected:bg-${customColor}`,
`ui-selected:border-${customColor}`,
`ui-selected:text-${customColor}`,
]),
"bg-[#e41e3b]",
"border-[#e41e3b]",
"hover:bg-[#e41e3b]",
"hover:border-[#e41e3b]",
"hover:text-[#e41e3b]",
"fill-[#e41e3b]",
"ring-[#e41e3b]",
"stroke-[#e41e3b]",
"text-[#e41e3b]",
"ui-selected:bg-[#e41e3b]",
"ui-selected:border-[#e41e3b]",
"ui-selected:text-[#e41e3b]",
"bg-[#ef8026]",
"border-[#ef8026]",
"hover:bg-[#ef8026]",
"hover:border-[#ef8026]",
"hover:text-[#ef8026]",
"fill-[#ef8026]",
"ring-[#ef8026]",
"stroke-[#ef8026]",
"text-[#ef8026]",
"ui-selected:bg-[#ef8026]",
"ui-selected:border-[#ef8026]",
"ui-selected:text-[#ef8026]",
"bg-[#f3a96a]",
"border-[#f3a96a]",
"hover:bg-[#f3a96a]",
"hover:border-[#f3a96a]",
"hover:text-[#f3a96a]",
"fill-[#f3a96a]",
"ring-[#f3a96a]",
"stroke-[#f3a96a]",
"text-[#f3a96a]",
"ui-selected:bg-[#f3a96a]",
"ui-selected:border-[#f3a96a]",
"ui-selected:text-[#f3a96a]",
"bg-[#f7cd14]",
"border-[#f7cd14]",
"hover:bg-[#f7cd14]",
"hover:border-[#f7cd14]",
"hover:text-[#f7cd14]",
"fill-[#f7cd14]",
"ring-[#f7cd14]",
"stroke-[#f7cd14]",
"text-[#f7cd14]",
"ui-selected:bg-[#f7cd14]",
"ui-selected:border-[#f7cd14]",
"ui-selected:text-[#f7cd14]",
"bg-[#8dbd40]",
"border-[#8dbd40]",
"hover:bg-[#8dbd40]",
"hover:border-[#8dbd40]",
"hover:text-[#8dbd40]",
"fill-[#8dbd40]",
"ring-[#8dbd40]",
"stroke-[#8dbd40]",
"text-[#8dbd40]",
"ui-selected:bg-[#8dbd40]",
"ui-selected:border-[#8dbd40]",
"ui-selected:text-[#8dbd40]",
"bg-[#2da55c]",
"border-[#2da55c]",
"hover:bg-[#2da55c]",
"hover:border-[#2da55c]",
"hover:text-[#2da55c]",
"fill-[#2da55c]",
"ring-[#2da55c]",
"stroke-[#2da55c]",
"text-[#2da55c]",
"ui-selected:bg-[#2da55c]",
"ui-selected:border-[#2da55c]",
"ui-selected:text-[#2da55c]",
"bg-[#117d58]",
"border-[#117d58]",
"hover:bg-[#117d58]",
"hover:border-[#117d58]",
"hover:text-[#117d58]",
"fill-[#117d58]",
"ring-[#117d58]",
"stroke-[#117d58]",
"text-[#117d58]",
"ui-selected:bg-[#117d58]",
"ui-selected:border-[#117d58]",
"ui-selected:text-[#117d58]",
],
plugins: [
function ({ addVariant }) {