Merge pull request #18 from Hestia-Homes/energy-assessment-page

Energy assessment page - implemented
This commit is contained in:
KhalimCK 2024-09-09 16:49:24 +01:00 committed by GitHub
commit e7536b4bb0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 554 additions and 17 deletions

View file

@ -2,6 +2,14 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next
## Getting Started
When first getting set up you'll firstly want to install the existing dependencies. To do this, simply run
```bash
npm install
# or
yarn install
```
First, run the development server:
```bash

View file

@ -0,0 +1,81 @@
// pages/api/get-presigned-url.ts
import S3 from "aws-sdk/clients/s3";
import STS from "aws-sdk/clients/sts"; // Import STS for temporary credentials
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
// Validate the input
const PresignedUrlBodySchema = z.object({
fileKey: z.string(),
});
// Function to get temporary credentials using GetSessionToken
async function getTemporaryCredentials() {
const sts = new STS({
accessKeyId: process.env.RETROFIT_ENERGY_ASSESSMENTS_AWS_ACCESS_KEY, // Your permanent access key
secretAccessKey: process.env.ENERGY_ASSESSMENTS_AWS_SECRET, // Your permanent secret access key
region: process.env.PRESIGN_AWS_REGION,
});
try {
// Request temporary credentials with GetSessionToken
const data = await sts.getSessionToken({ DurationSeconds: 900 }).promise(); // Token valid for 15 minutes
// Check if credentials are present
if (!data.Credentials) {
throw new Error("Failed to retrieve temporary credentials");
}
return data.Credentials;
} catch (error) {
console.error("Error fetching temporary credentials:", error);
throw error;
}
}
// API handler
export async function POST(request: NextRequest) {
const body = await request.json();
let validatedBody;
try {
validatedBody = PresignedUrlBodySchema.parse(body);
} catch (error) {
console.error("Invalid input: ", error);
return new NextResponse(JSON.stringify({ msg: "Invalid input" }), {
status: 400,
});
}
try {
// Get temporary credentials using GetSessionToken
const credentials = await getTemporaryCredentials();
// Initialize S3 with temporary credentials
const s3 = new S3({
signatureVersion: "v4",
region: process.env.PRESIGN_AWS_REGION,
accessKeyId: credentials.AccessKeyId,
secretAccessKey: credentials.SecretAccessKey,
sessionToken: credentials.SessionToken, // Include session token
});
const { fileKey } = validatedBody;
// Generate presigned URL valid for 5 minutes
const preSignedUrl = await s3.getSignedUrl("getObject", {
Bucket: process.env.RETROFIT_ENERGY_ASSESSMENTS_BUCKET,
Key: fileKey,
Expires: 5 * 60, // URL expiration in seconds
});
return new NextResponse(JSON.stringify({ url: preSignedUrl }), {
status: 200,
});
} catch (error) {
console.error("Error generating presigned URL:", error);
return new NextResponse(JSON.stringify({ msg: "Internal server error" }), {
status: 500,
});
}
}

View file

@ -18,3 +18,31 @@ export function TanButton({
</button>
);
}
export function BrandButton({
label,
onClick,
backgroundColor,
}: {
label: string;
onClick: Dispatch<SetStateAction<any>>;
backgroundColor: "brandblue" | "brandgold"; // Restrict backgroundColor to these two options
}) {
// Dictionary to map background colors to hover colors
const hoverColors = {
brandblue: "hover:bg-hoverblue",
brandgold: "hover:bg-hovergold",
};
return (
<button
type="button"
className={`inline-flex justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2
${backgroundColor === "brandblue" ? "bg-brandblue" : "bg-brandgold"}
${hoverColors[backgroundColor]}`}
onClick={onClick}
>
{label}
</button>
);
}

View file

@ -20,7 +20,11 @@ export default function StatusBadge({
return (
<HoverCard>
<HoverCardTrigger>
<Badge className={statusConfig.class}>{statusConfig.text}</Badge>
<Badge
className={`truncate overflow-hidden whitespace-nowrap ${statusConfig.class}`}
>
{statusConfig.text}
</Badge>
</HoverCardTrigger>
<HoverCardContent>
<div>

View file

@ -6,12 +6,14 @@ export default function EpcCard({
expected = false,
kwh = null,
carbon = null,
sap = null,
}: {
epcRating: string;
fullMargin: boolean;
expected?: boolean;
kwh?: number | null;
carbon?: number | null;
sap?: string | null;
}) {
let marginClass = "";
if (fullMargin) {
@ -30,28 +32,35 @@ export default function EpcCard({
return (
<div
className={
"flex flex-col items-center p-4 shadow rounded-md max-w-xl justify-start text-gray-100 " +
"flex flex-col items-center p-4 shadow rounded-md max-w-xl justify-start text-gray-100 " +
bgColour
}
>
<div className="text-xl font-bold mb-4 text-center">{title}</div>
<div className="text-6xl font-bold ">{epcRating}</div>
{/* EPC Rating and SAP Rating */}
<div className="flex items-baseline justify-center">
{/* EPC Rating */}
<div className="text-6xl font-bold">{epcRating}</div>
{/* SAP Rating (only if sap is provided) */}
{sap !== null && (
<div className="text-lg font-medium ml-2 text-gray-100">{sap}</div>
)}
</div>
{(kwh || carbon) && (
<table className="mt-6">
<tbody>
{kwh && (
<tr>
{" "}
{/* Added vertical padding to each row */}
<td className="text-gray-50">{kwh.toFixed(0)} kWh</td>
</tr>
)}
{carbon && (
<tr>
{" "}
<td className="text-gray-50 py-2">
{carbon}t CO <sub>2</sub>
{carbon}t CO<sub>2</sub>
</td>
</tr>
)}

View file

@ -17,6 +17,7 @@ const alreadyInstalledStyling =
const TitleMap = {
mechanical_ventilation: "Mechanical Ventilation",
trickle_vents: "Trickle Vents",
sealing_open_fireplace: "Sealing Open Fireplace",
low_energy_lighting: "Low Energy Lighting",
// Walls
@ -33,6 +34,7 @@ const TitleMap = {
exposed_floor_insulation: "Exposed Floor Insulation",
// Windows
windows_glazing: "Window Glazing",
mixed_glazing: "Mixed - Secondary and Double Glazing",
// Solar pv
solar_pv: "Solar Photovoltaic Panels System",
// Heating
@ -47,6 +49,8 @@ const TitleMap = {
roof_insulation: "Roof Insulation",
// Cylinder thermostat
cylinder_thermostat: "Cylinder Thermostat",
// Draught proofing
draught_proofing: "Draught Proofing",
};
type RecommendationCardProps = {

View file

@ -127,6 +127,21 @@ export default function RecommendationContainer({
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultTrickleVentsRecommendations =
categorizedRecommendations.trickle_vents?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultMixedGlazingRecommendations =
categorizedRecommendations.mixed_glazing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const defaultDraughtProofingRecommendations =
categorizedRecommendations.draught_proofing?.find(
(rec: Recommendation) => rec.default
) || emptyImpactState;
const [costMap, setCostMap] = useState<RecommendationMetricMap>({
wall_insulation: defaultWallsRecommendations.estimatedCost || 0,
floor_insulation: defaultFloorRecommendations.estimatedCost || 0,
@ -145,6 +160,9 @@ export default function RecommendationContainer({
defaultSecondaryHeatingRecommendations.estimatedCost || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.estimatedCost || 0,
trickle_vents: defaultTrickleVentsRecommendations.estimatedCost || 0,
mixed_glazing: defaultMixedGlazingRecommendations.estimatedCost || 0,
draught_proofing: defaultDraughtProofingRecommendations.estimatedCost || 0,
});
const [sapMap, setSapMap] = useState<RecommendationMetricMap>({
@ -163,6 +181,9 @@ export default function RecommendationContainer({
secondary_heating: defaultSecondaryHeatingRecommendations.sapPoints || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.sapPoints || 0,
trickle_vents: defaultTrickleVentsRecommendations.sapPoints || 0,
mixed_glazing: defaultMixedGlazingRecommendations.sapPoints || 0,
draught_proofing: defaultDraughtProofingRecommendations.sapPoints || 0,
});
const [labourDaysMap, setLabourDaysMap] = useState<RecommendationMetricMap>({
@ -181,6 +202,9 @@ export default function RecommendationContainer({
secondary_heating: defaultSecondaryHeatingRecommendations.labourDays || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.labourDays || 0,
trickle_vents: defaultTrickleVentsRecommendations.labourDays || 0,
mixed_glazing: defaultMixedGlazingRecommendations.labourDays || 0,
draught_proofing: defaultDraughtProofingRecommendations.labourDays || 0,
});
const [co2SavingsMap, setCo2SavingsMap] = useState<RecommendationMetricMap>({
@ -204,6 +228,10 @@ export default function RecommendationContainer({
defaultSecondaryHeatingRecommendations.co2EquivalentSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.co2EquivalentSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.co2EquivalentSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.co2EquivalentSavings || 0,
draught_proofing:
defaultDraughtProofingRecommendations.co2EquivalentSavings || 0,
});
const [energyCostSavingsMap, setEnergyCostSavingsMap] =
@ -228,6 +256,10 @@ export default function RecommendationContainer({
defaultSecondaryHeatingRecommendations.energyCostSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.energyCostSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.energyCostSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.energyCostSavings || 0,
draught_proofing:
defaultDraughtProofingRecommendations.energyCostSavings || 0,
});
const [kwhSavingsMap, setKwhSavingsMap] = useState<RecommendationMetricMap>({
@ -246,6 +278,9 @@ export default function RecommendationContainer({
secondary_heating: defaultSecondaryHeatingRecommendations.kwhSavings || 0,
cylinder_thermostat:
defaultCylinderThermostatRecommendations.kwhSavings || 0,
trickle_vents: defaultTrickleVentsRecommendations.kwhSavings || 0,
mixed_glazing: defaultMixedGlazingRecommendations.kwhSavings || 0,
draught_proofing: defaultDraughtProofingRecommendations.kwhSavings || 0,
});
const [totalEstimatedCost, setTotalEstimatedCost] = useState(

View file

@ -39,6 +39,16 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
</NavigationMenuLink>
);
const energyAssessmentsReportButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/energy-assessment`}
>
<NewspaperIcon className="h-4 w-4 mr-2" />
Energy Assessment
</NavigationMenuLink>
);
const solarAnalysisButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
@ -73,13 +83,7 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
{preAssessmentReportButton}
{solarAnalysisButton}
{recommendationsButton}
{/* <NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/plan-optimiser`}
>
<LightBulbIcon className="h-4 w-4 mr-2" />
Plan optimiser
</NavigationMenuLink> */}
{energyAssessmentsReportButton}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}

View file

@ -195,3 +195,17 @@ export const energyAssessmentDocuments = pgTable(
// Types for the new table
export type EnergyAssessment = InferModel<typeof energyAssessment, "select">;
export type EnergyAssessmentScenario = InferModel<
typeof energyAssessmentScenarios,
"select"
>;
export type EnergyAssessmentDocument = InferModel<
typeof energyAssessmentDocuments,
"select"
>;
// We define a type for the energyassessment docments that embeds a scenario in
// the document
export type EnergyAssessmentDocumentWithScenario = EnergyAssessmentDocument & {
scenario: EnergyAssessmentScenario | null;
};

View file

@ -185,7 +185,10 @@ export type RecommendationType =
| "hot_water_tank_insulation"
| "heating_control"
| "secondary_heating"
| "cylinder_thermostat";
| "cylinder_thermostat"
| "trickle_vents"
| "mixed_glazing"
| "draught_proofing";
export type UnnestedRecommendation = {
quantity: number;

View file

@ -1,3 +1,7 @@
import {
energyAssessmentDocuments,
energyAssessmentScenarios,
} from "./energy_assessments";
// This script contains ALL relations for the database, used by drizzle-orm
import { relations } from "drizzle-orm";
@ -139,3 +143,14 @@ export const nonIntrusiveSurveyNotesRelations = relations(
}),
})
);
// Define a relation from a EnergyAssessmentDocument to EnergyAssessmentScenario. This is a many to one
export const energyAssessmentDocumentsRelations = relations(
energyAssessmentDocuments,
({ one }) => ({
scenario: one(energyAssessmentScenarios, {
fields: [energyAssessmentDocuments.scenarioId],
references: [energyAssessmentScenarios.id],
}),
})
);

View file

@ -10,7 +10,6 @@ import { Inter } from "next/font/google";
// If loading a variable font, you don't need to specify the font weight
const inter = Inter({
subsets: ["latin"],
display: "swap",
});
export const metadata = {

View file

@ -0,0 +1,102 @@
"use client";
import React from "react";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { BrandButton } from "@/app/components/Buttons";
import { useMutation } from "@tanstack/react-query";
import { DocumentType } from "@/app/db/schema/energy_assessments";
// Use the type directly from the array
type Document = {
id: bigint;
documentType: (typeof DocumentType)[number]; // Create a union type from the array
documentLocation: string; // S3 file key
uploadedAt: Date;
};
type Props = {
documents: Document[];
allowedTypes: (typeof DocumentType)[number][]; // Use the union type for allowedTypes as well
};
// Descriptions based on the document types
const descriptions: { [key in (typeof DocumentType)[number]]: string } = {
EPR: "Contains current energy performance of your home, and recommendations for improvement",
"Condition Report":
"Detailed report on property condition, including photographs",
"Evidence Report": "Additional photographic evidence from the survey",
"Summary Information": "Summarized survey outputs and site notes",
"Floor Plan": "Layout of the property and room/window dimensions",
"Scenario Draft EPC": "Draft EPC based on a given scenario",
"Scenario Site Notes": "Site notes from the scenario analysis",
};
// Fetch the presigned URL from the API
async function generatePresignedUrl(fileKey: string) {
const response = await fetch("/api/energy-assessment-documents", {
method: "POST",
body: JSON.stringify({ fileKey }),
});
if (!response.ok) {
throw new Error("Failed to generate presigned URL");
}
const data = await response.json();
return data.url;
}
export const DocumentsTable: React.FC<Props> = ({
documents,
allowedTypes,
}) => {
const relevantDocuments = documents.filter((doc) =>
allowedTypes.includes(doc.documentType)
);
// Mutation to handle the presigned URL generation
const { mutate: fetchPresignedUrl } = useMutation(
async (fileKey: string) => await generatePresignedUrl(fileKey),
{
onSuccess: (url) => {
window.open(url, "_blank"); // Open the file in a new tab
},
onError: (error) => {
console.error("Error generating presigned URL:", error);
},
}
);
const handleDownload = (documentLocation: string) => {
fetchPresignedUrl(documentLocation); // Generate URL and open in new tab
};
return (
<Table className="min-w-full 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">
{relevantDocuments.map((doc) => (
<TableRow key={doc.id.toString()}>
<TableCell className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{doc.documentType}
</TableCell>
<TableCell className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{descriptions[doc.documentType] || ""}
</TableCell>
<TableCell className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<BrandButton
label="Download"
onClick={() => handleDownload(doc.documentLocation)} // Call the download handler
backgroundColor="brandgold"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View file

@ -0,0 +1,186 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/app/shadcn_components/ui/card";
import {
getEnergyAssessment,
getEnergyAssessmentDocuments,
getPropertyMeta,
} from "../utils";
import { DocumentsTable } from "./DocumentsTable";
import { getEpcColorClass } from "@/app/utils";
// Helper function to clean scenario names
function cleanScenarioName(scenarioName: string | undefined) {
if (!scenarioName) {
return scenarioName;
}
return scenarioName.startsWith("Scenario")
? scenarioName.replace(/^Scenario\s*/i, "").trim()
: scenarioName;
}
type InfoCardProps = {
title: string;
value: number | string;
unit: string;
};
const InfoCard: React.FC<InfoCardProps> = ({ title, value, unit }) => {
const isEnergyRating = title === "Energy Rating";
const bgColorClass = isEnergyRating ? getEpcColorClass(value.toString()) : ""; // Get the EPC color if it's Energy Rating
return (
<Card
className={`flex-1 max-w-xs ${isEnergyRating ? bgColorClass : ""} ${
isEnergyRating ? "text-white" : ""
}`}
>
<CardHeader className="text-center">
<CardTitle
className={`${
isEnergyRating ? "text-white" : "text-gray-700"
} text-sm`}
>
{title}
</CardTitle>
</CardHeader>
<CardContent className="text-center p-6">
<div
className={`flex justify-center items-baseline ${
isEnergyRating ? "text-white" : "text-gray-800"
}`}
>
<span className="text-4xl font-bold">{value}</span>
<span className="ml-1 text-sm">{unit}</span>
</div>
</CardContent>
</Card>
);
};
export default async function EnergyAssessmentsPage({
params,
}: {
params: { slug: string; propertyId: string };
}) {
const propertyMeta = await getPropertyMeta(params.propertyId);
const ea = await getEnergyAssessment(propertyMeta.uprn);
if (!ea) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center text-gray-500">
<div className="text-2xl font-semibold mb-4">
This property does not have a Domna energy assessment
</div>
<p className="text-lg">Please check back later for updates.</p>
</div>
</div>
);
}
const documents = await getEnergyAssessmentDocuments(ea.id);
const table1AllowedTypes = [
"EPR",
"Condition Report",
"Evidence Report",
"Summary Information",
"Floor Plan",
];
const scenarioAllowedTypes = ["Scenario Draft EPC", "Scenario Site Notes"];
// Separate core documents and scenario-specific documents
const coreDocuments = documents.filter(
(doc) =>
doc.scenarioId === null && table1AllowedTypes.includes(doc.documentType)
);
const scenarioDocuments = documents.filter(
(doc) =>
doc.scenarioId !== null && scenarioAllowedTypes.includes(doc.documentType)
);
// Extract unique scenarios from the documents
const scenarios = Array.from(
new Set(scenarioDocuments.map((doc) => doc.scenario?.scenarioName))
).filter(Boolean); // Filter out any null or undefined scenario names
return (
<div>
{/* EPC Card and Summary Information */}
<div className="flex flex-row justify-between my-8 space-x-6 w-full">
{/* EPC Rating Card */}
<InfoCard
title="Energy Rating"
value={ea.currentEnergyRating}
unit={ea.currentEnergyEfficiency}
/>
<InfoCard
title="Carbon Emissions"
value={ea.co2EmissionsCurrent}
unit="tCO2/year"
/>
<InfoCard
title="Heat Demand"
value={ea.energyConsumptionCurrent}
unit="kWh/m²/year"
/>
<InfoCard
title="Est. Heating kWh"
value={ea.spaceHeatingKwh}
unit="kWh"
/>
{/* Estimated Hot Water kWh */}
<InfoCard
title="Est. Hot Water kWh"
value={ea.waterHeatingKwh}
unit="kWh"
/>
</div>
{/* Core Survey Documents */}
<div className="mt-6">
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Core Survey Documents
</div>
<div className="py-4">
<DocumentsTable
documents={coreDocuments}
allowedTypes={table1AllowedTypes}
/>
</div>
</div>
{/* Scenario-Specific Documents */}
{scenarios.map((scenarioName) => {
const scenarioDocs = scenarioDocuments.filter(
(doc) => doc.scenario?.scenarioName === scenarioName
);
return (
<div key={scenarioName} className="py-4">
<div className="flex items-center justify-between py-4 px-6 bg-brandblue text-white font-semibold text-lg rounded-md">
Scenario: {cleanScenarioName(scenarioName)}
</div>
<DocumentsTable
documents={scenarioDocs}
allowedTypes={scenarioAllowedTypes}
/>
</div>
);
})}
</div>
);
}

View file

@ -1,4 +1,3 @@
import { recommendation } from "../../../../db/schema/recommendations";
import {
Recommendation,
planRecommendations,
@ -18,11 +17,53 @@ import {
import { plan, Plan } from "@/app/db/schema/recommendations";
import { getRating } from "@/app/utils";
import { eq, desc } from "drizzle-orm";
import {
energyAssessment,
EnergyAssessment,
energyAssessmentDocuments,
EnergyAssessmentDocument,
EnergyAssessmentDocumentWithScenario,
} from "@/app/db/schema/energy_assessments";
type RecommendationList = {
recommendation: Recommendation;
}[];
export async function getEnergyAssessment(
uprn: number
): Promise<EnergyAssessment> {
const data = await db.query.energyAssessment.findFirst({
where: eq(energyAssessment.uprn, BigInt(uprn)),
});
if (!data) {
// If there's no data, we return an empty array. This signififies that no energy assessment has been conducted
return {} as EnergyAssessment;
}
return data;
}
export async function getEnergyAssessmentDocuments(
energyAssessmentId: bigint
): Promise<EnergyAssessmentDocumentWithScenario[]> {
const data = await db.query.energyAssessmentDocuments.findMany({
where: eq(
energyAssessmentDocuments.energyAssessmentId,
BigInt(energyAssessmentId)
),
with: {
scenario: true,
},
});
if (!data) {
throw new Error("Network response was not ok");
}
return data;
}
export async function getRecommendations(
planId: string
): Promise<Recommendation[]> {

View file

@ -12,4 +12,7 @@ export interface RecommendationMetricMap {
heating_control: number;
secondary_heating: number;
cylinder_thermostat: number;
trickle_vents: number;
mixed_glazing: number;
draught_proofing: number;
}

View file

@ -101,6 +101,7 @@ module.exports = {
brandtan: "#d3b488",
hovertan: "#947750",
brandgold: "#f1bb06",
hovergold: "#c79d12",
brandbrown: "#3d1e05",
brandmidblue: "#3943b7",
border: "hsl(var(--border))",