diff --git a/README.md b/README.md index b58ac46..e720d02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/api/energy-assessment-documents/route.ts b/src/app/api/energy-assessment-documents/route.ts new file mode 100644 index 0000000..86512b7 --- /dev/null +++ b/src/app/api/energy-assessment-documents/route.ts @@ -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, + }); + } +} diff --git a/src/app/components/Buttons.tsx b/src/app/components/Buttons.tsx index 7da4ab2..9d2d87d 100644 --- a/src/app/components/Buttons.tsx +++ b/src/app/components/Buttons.tsx @@ -18,3 +18,31 @@ export function TanButton({ ); } + +export function BrandButton({ + label, + onClick, + backgroundColor, +}: { + label: string; + onClick: Dispatch>; + 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 ( + + ); +} diff --git a/src/app/components/StatusBadge.tsx b/src/app/components/StatusBadge.tsx index ec66279..f4818c3 100644 --- a/src/app/components/StatusBadge.tsx +++ b/src/app/components/StatusBadge.tsx @@ -20,7 +20,11 @@ export default function StatusBadge({ return ( - {statusConfig.text} + + {statusConfig.text} +
diff --git a/src/app/components/building-passport/EpcCard.tsx b/src/app/components/building-passport/EpcCard.tsx index 8b9ca1f..7f4df5c 100644 --- a/src/app/components/building-passport/EpcCard.tsx +++ b/src/app/components/building-passport/EpcCard.tsx @@ -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 (
{title}
-
{epcRating}
+ + {/* EPC Rating and SAP Rating */} +
+ {/* EPC Rating */} +
{epcRating}
+ + {/* SAP Rating (only if sap is provided) */} + {sap !== null && ( +
{sap}
+ )} +
{(kwh || carbon) && ( {kwh && ( - {" "} - {/* Added vertical padding to each row */} )} {carbon && ( - {" "} )} diff --git a/src/app/components/building-passport/RecommendationCard.tsx b/src/app/components/building-passport/RecommendationCard.tsx index cace886..f53826c 100644 --- a/src/app/components/building-passport/RecommendationCard.tsx +++ b/src/app/components/building-passport/RecommendationCard.tsx @@ -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 = { diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index 951e6af..50cf27b 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -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({ 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({ @@ -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({ @@ -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({ @@ -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({ @@ -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( diff --git a/src/app/components/building-passport/Toolbar.tsx b/src/app/components/building-passport/Toolbar.tsx index 8f57d21..58866ed 100644 --- a/src/app/components/building-passport/Toolbar.tsx +++ b/src/app/components/building-passport/Toolbar.tsx @@ -39,6 +39,16 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) { ); + const energyAssessmentsReportButton = ( + + + Energy Assessment + + ); + const solarAnalysisButton = ( - - Plan optimiser - */} + {energyAssessmentsReportButton} ; +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; +}; diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index b8899bb..d272b2a 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -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; diff --git a/src/app/db/schema/relations.ts b/src/app/db/schema/relations.ts index dfc44bd..87e7d4c 100644 --- a/src/app/db/schema/relations.ts +++ b/src/app/db/schema/relations.ts @@ -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], + }), + }) +); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1aa6c8e..c2f4e51 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 = { diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/DocumentsTable.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/DocumentsTable.tsx new file mode 100644 index 0000000..e142b93 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/DocumentsTable.tsx @@ -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 = ({ + 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 ( +
{kwh.toFixed(0)} kWh
- {carbon}t CO 2 + {carbon}t CO2
+ + {relevantDocuments.map((doc) => ( + + + {doc.documentType} + + + {descriptions[doc.documentType] || ""} + + + handleDownload(doc.documentLocation)} // Call the download handler + backgroundColor="brandgold" + /> + + + ))} + +
+ ); +}; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/page.tsx new file mode 100644 index 0000000..871f9b0 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/energy-assessment/page.tsx @@ -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 = ({ title, value, unit }) => { + const isEnergyRating = title === "Energy Rating"; + const bgColorClass = isEnergyRating ? getEpcColorClass(value.toString()) : ""; // Get the EPC color if it's Energy Rating + + return ( + + + + {title} + + + +
+ {value} + {unit} +
+
+
+ ); +}; + +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 ( +
+
+
+ This property does not have a Domna energy assessment +
+

Please check back later for updates.

+
+
+ ); + } + + 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 ( +
+ {/* EPC Card and Summary Information */} +
+ {/* EPC Rating Card */} + + + + + + + + + + {/* Estimated Hot Water kWh */} + +
+ + {/* Core Survey Documents */} +
+
+ Core Survey Documents +
+
+ +
+
+ + {/* Scenario-Specific Documents */} + {scenarios.map((scenarioName) => { + const scenarioDocs = scenarioDocuments.filter( + (doc) => doc.scenario?.scenarioName === scenarioName + ); + + return ( +
+
+ Scenario: {cleanScenarioName(scenarioName)} +
+ +
+ ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index d33e968..37ded61 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -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 { + 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 { + 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 { diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index e113988..fd0f610 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -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; } diff --git a/tailwind.config.js b/tailwind.config.js index 8e2051d..7521e9e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -101,6 +101,7 @@ module.exports = { brandtan: "#d3b488", hovertan: "#947750", brandgold: "#f1bb06", + hovergold: "#c79d12", brandbrown: "#3d1e05", brandmidblue: "#3943b7", border: "hsl(var(--border))",