From 6cffb3a3d2f96bcd1b47f0d155431738b13f788d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 21 Aug 2025 21:35:08 +0000 Subject: [PATCH] first implementation of funding into the app --- src/app/components/Buttons.tsx | 4 +- .../EnergyEfficiencyImpactCard.tsx | 118 ++++++++++-------- .../RecommendationContainer.tsx | 12 ++ .../ValuationImpactComponent.tsx | 87 +++++++++---- .../building-passport/WorksPackageCard.tsx | 29 +++-- src/app/db/schema/funding.ts | 9 +- src/app/db/schema/relations.ts | 19 ++- .../[propertyId]/plans/[planId]/page.tsx | 1 + .../building-passport/[propertyId]/utils.ts | 8 +- src/app/utils.ts | 30 +++-- 10 files changed, 214 insertions(+), 103 deletions(-) diff --git a/src/app/components/Buttons.tsx b/src/app/components/Buttons.tsx index 962e9cf..e21d600 100644 --- a/src/app/components/Buttons.tsx +++ b/src/app/components/Buttons.tsx @@ -30,7 +30,8 @@ export function BrandButton({ | "brandblue" | "brandgold" | "brandmidblue" - | "brandlightblue"; // Restrict backgroundColor to these two options + | "brandlightblue" + | "brandbrown" ; // Restrict backgroundColor to these options }) { // Dictionary to map background colors to hover colors const hoverColors = { @@ -38,6 +39,7 @@ export function BrandButton({ brandgold: "hover:bg-hovergold", brandmidblue: "hover:bg-hoverblue", brandlightblue: "hover:bg-brandmidblue", + brandbrown: "hover:bg-brandbrown", }; return ( diff --git a/src/app/components/building-passport/EnergyEfficiencyImpactCard.tsx b/src/app/components/building-passport/EnergyEfficiencyImpactCard.tsx index 1ff4ff0..f250aca 100644 --- a/src/app/components/building-passport/EnergyEfficiencyImpactCard.tsx +++ b/src/app/components/building-passport/EnergyEfficiencyImpactCard.tsx @@ -14,41 +14,47 @@ export function EnergyEfficiencyImpactCard({ totalSapPoints, }: EnergyEfficiencyImpactCardProps) { return ( -
- - - - - +
Energy Efficiency Impact
+ + + + - - - - + {/* Divider row */} + + + - - - - + + + + - - - - - -
+ Energy Efficiency Impact +
Current EPC Rating: - {currentSapPoints + " " + currentEpcRating} -
+
+
Expected EPC Rating: - {Math.floor(expectedSapPoints) + " " + expectedEpcRating} -
Current EPC Rating: + {currentSapPoints} {currentEpcRating} +
- Total SAP Points Improvement: - - {Math.round((totalSapPoints + Number.EPSILON) * 100) / 100} -
-
+ + Expected EPC Rating: + + {Math.floor(expectedSapPoints)} {expectedEpcRating} + + + + + Total SAP Points Improvement: + + {Math.round((totalSapPoints + Number.EPSILON) * 100) / 100} + + + + ); } + interface SecondaryEnergyEfficiencyImpactCardProps { TotalCo2Savings: number; totalEnergyCostSavings: number; @@ -61,31 +67,39 @@ export function SecondaryEnergyEfficiencyImpactCard({ totalKwhSavings, }: SecondaryEnergyEfficiencyImpactCardProps) { return ( -
- - - - - +
+ + + + - - - - + {/* Divider row */} + + + - - - - + + + + - - - - - -
+ Gains +
- CO2 Reduction - {TotalCo2Savings.toFixed(1)}t
+
+
Energy Savings{totalKwhSavings.toFixed(0) + "kWh"}
+ CO2 Reduction + {TotalCo2Savings.toFixed(1)}t
Energy Bill Savings{"£" + Math.round(totalEnergyCostSavings)}
-
+ + Energy Savings + {totalKwhSavings.toFixed(0)}kWh + + + + Energy Bill Savings + £{Math.round(totalEnergyCostSavings)} + + + ); } + diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index d6c5bd8..6c3211b 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -18,11 +18,13 @@ import { EnergyEfficiencyImpactCard, SecondaryEnergyEfficiencyImpactCard, } from "./EnergyEfficiencyImpactCard"; +import { FundingPackageWithMeasures } from "@/app/db/schema/funding"; interface RecommendationContainerProps { recommendations: Recommendation[]; propertyMeta: PropertyMeta; planMeta: Plan; + funding: FundingPackageWithMeasures[] } const typeToCategoryMap: { [key in RecommendationType]?: RecommendationType } = @@ -54,6 +56,7 @@ export default function RecommendationContainer({ recommendations, propertyMeta, planMeta, + funding }: RecommendationContainerProps) { const categorizedRecommendations = recommendations.reduce((acc, curr) => { const typeKey = curr.type as RecommendationType; @@ -312,6 +315,13 @@ export default function RecommendationContainer({ sumRecommendationMetricMap(kwhSavingsMap) ); + // for the moment, we shouldn't have more than one funding package and so we flag if we have more than one + if (funding.length > 1) { + console.warn("Multiple funding packages found, using the first one."); + } + + const [totalFunding, setTotalFunding] = useState(funding[0].projectFunding) + const currentEpcRating = propertyMeta.currentEpcRating; const currentSapPoints = propertyMeta.currentSapPoints; @@ -326,6 +336,7 @@ export default function RecommendationContainer({ diff --git a/src/app/components/building-passport/ValuationImpactComponent.tsx b/src/app/components/building-passport/ValuationImpactComponent.tsx index 17ce12a..f5f797c 100644 --- a/src/app/components/building-passport/ValuationImpactComponent.tsx +++ b/src/app/components/building-passport/ValuationImpactComponent.tsx @@ -1,16 +1,67 @@ "use client"; -import { BrandButton } from "../Buttons"; import { useState } from "react"; +import React from 'react' +import { FundingPackageWithMeasures } from "@/app/db/schema/funding"; +import { formatNumber } from "@/app/utils"; +import {Card, CardContent} from "@/app/shadcn_components/ui/card"; +import { Button } from "@/app/shadcn_components/ui/button"; +import { PiggyBank } from 'lucide-react' + + +type FundingSummaryProps = { + scheme: string | null; + onSeeMore: () => void +} + +export const FundingSummary: React.FC = ({ scheme, onSeeMore }) => { + let message: string | null = null + + if (!scheme) { + message = 'Funding not assessed for this measure package' + } else if (scheme.toLowerCase() === 'none') { + message = 'Funding not eligible for this measure package' + } + + return ( + + + {message ? ( +

{message}

+ ) : ( + <> +
+ {scheme} + +
+

+ Click below to learn more about available funding options. +

+ + + )} +
+
+ ) +} export default function ValuationImpactComponent({ currentValuation, valuationIncreaseLowerBound, valuationIncreaseUpperBound, + funding, }: { currentValuation: number | null; valuationIncreaseLowerBound: number | null; valuationIncreaseUpperBound: number | null; + funding: FundingPackageWithMeasures; }) { const [fundingModalIsOpen, setFundingModalIsOpen] = useState(false); // If we have no current valuation, we return no component @@ -29,34 +80,28 @@ export default function ValuationImpactComponent({ return (
-
- Current Value - - £{currentValuation.toLocaleString()} +
+ Current Property Value + + £{formatNumber(currentValuation)}
-
+
After Retrofit Valuation -
- £{lowerBoundValuation.toLocaleString()} - £ - {upperBoundValuation.toLocaleString()} +
+ £{formatNumber(lowerBoundValuation)} - £{formatNumber(upperBoundValuation)}
- - Estimated improvement: £ - {valuationIncreaseLowerBound?.toLocaleString()} - £ - {valuationIncreaseUpperBound?.toLocaleString()} + Estimated improvement: + + £{formatNumber(valuationIncreaseLowerBound || 0)} - £{formatNumber(valuationIncreaseUpperBound || 0)}
-
- Funding Options - -
+
); } diff --git a/src/app/components/building-passport/WorksPackageCard.tsx b/src/app/components/building-passport/WorksPackageCard.tsx index a07fd40..902dcc0 100644 --- a/src/app/components/building-passport/WorksPackageCard.tsx +++ b/src/app/components/building-passport/WorksPackageCard.tsx @@ -4,32 +4,45 @@ import { convertDaysToWorkingWeeks, formatNumber } from "@/app/utils"; export default function WorksPackageCard({ totalEstimatedCost, totalLabourDays, + totalFunding, }: { totalEstimatedCost: number; totalLabourDays: number; + totalFunding: number | null; }) { return ( - +
- + + + + {/* Divider row */} + + - - + + - - + + - - + +
Works Package + Works Package +
+
+
Total Cost:{"£" + formatNumber(totalEstimatedCost)}Total Cost:{"£" + formatNumber(totalEstimatedCost)}
Trades required:{"1-2"}Total Funding:{"£" + formatNumber(totalFunding || 0)}
Estimated Duration:{convertDaysToWorkingWeeks(totalLabourDays)}Estimated Duration:{convertDaysToWorkingWeeks(totalLabourDays)}
); } + + diff --git a/src/app/db/schema/funding.ts b/src/app/db/schema/funding.ts index d3976e6..6924604 100644 --- a/src/app/db/schema/funding.ts +++ b/src/app/db/schema/funding.ts @@ -23,7 +23,7 @@ export const fundingPackage = pgTable("funding_package", { .references(() => plan.id), scheme: Scheme("scheme"), createdAt: timestamp("created_at").notNull().defaultNow(), - rojectFunding: real("project_funding"), + projectFunding: real("project_funding"), totalUplift: real("total_uplift"), fullProjectScore: real("full_project_score"), partialProjectScore: real("partial_project_score"), @@ -44,4 +44,9 @@ export const fundingPackageMeasures = pgTable("funding_package_measures", { }); -export type FundingPackage = typeof fundingPackage.$inferSelect; \ No newline at end of file +export type FundingPackage = typeof fundingPackage.$inferSelect; +export type FundingPackageMeasure = typeof fundingPackageMeasures.$inferSelect; + +export type FundingPackageWithMeasures = FundingPackage & { + fundingPackageMeasures: FundingPackageMeasure[]; +}; \ No newline at end of file diff --git a/src/app/db/schema/relations.ts b/src/app/db/schema/relations.ts index 87e7d4c..d21ebff 100644 --- a/src/app/db/schema/relations.ts +++ b/src/app/db/schema/relations.ts @@ -1,8 +1,8 @@ +// This script contains ALL relations for the database, used by drizzle-orm import { energyAssessmentDocuments, energyAssessmentScenarios, } from "./energy_assessments"; -// This script contains ALL relations for the database, used by drizzle-orm import { relations } from "drizzle-orm"; import { @@ -21,6 +21,7 @@ import { import { material } from "./materials"; import { portfolio, portfolioUsers } from "./portfolio"; import { user } from "./users"; +import { fundingPackage, fundingPackageMeasures } from "./funding"; // Define the other side of the one to many relation between a property and its recomendations export const recommendationsRelations = relations( @@ -154,3 +155,19 @@ export const energyAssessmentDocumentsRelations = relations( }), }) ); + +// Relation from a funding package to funding package measures +// Define a relation from a EnergyAssessmentDocument to EnergyAssessmentScenario. This is a many to one + +// funding package links to multiple funding package measures +export const fundingPackageRelations = relations(fundingPackage, ({ many }) => ({ + fundingPackageMeasures: many(fundingPackageMeasures), +})); + +// funding package measures belong to a funding package +export const fundingPackageMeasuresRelations = relations(fundingPackageMeasures, ({ one }) => ({ + fundingPackage: one(fundingPackage, { + fields: [fundingPackageMeasures.fundingPackageId], + references: [fundingPackage.id], + }), +})); diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx index 05efebb..a51eee2 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx @@ -21,6 +21,7 @@ export default async function Recommendations( recommendations={recommendations} propertyMeta={propertyMeta} planMeta={planMeta} + funding={funding} />
); diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index b5e11d8..d737e33 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -26,16 +26,20 @@ import { } from "@/app/db/schema/energy_assessments"; import { fundingPackage, - FundingPackage + FundingPackage, + FundingPackageWithMeasures } from "@/app/db/schema/funding"; type RecommendationList = { recommendation: Recommendation; }[]; -export async function getPlanFunding(planId: string): Promise { +export async function getPlanFunding(planId: string): Promise { const data = await db.query.fundingPackage.findMany({ where: eq(fundingPackage.planId, BigInt(planId)), + with: { + fundingPackageMeasures: true, + }, }); if (!data) { diff --git a/src/app/utils.ts b/src/app/utils.ts index 319ae0c..b2f3705 100644 --- a/src/app/utils.ts +++ b/src/app/utils.ts @@ -129,31 +129,29 @@ export function formatDateTime(dateTimeString: string | Date): string { } export function formatNumber(number: number): string { - if (number === 0) { - return "0"; - } + if (number === 0) return "0"; - const suffixes: string[] = ["", "k", "m", "b", "t"]; - const suffixIndex: number = Math.floor(Math.log10(Math.abs(number)) / 3); + const suffixes = ["", "k", "m", "b", "t"]; + const suffixIndex = Math.floor(Math.log10(Math.abs(number)) / 3); - // Check if the suffix index is within the available suffixes if (suffixIndex >= suffixes.length) { - return number.toString(); // Return the number as is + return number.toString(); } - // Check if the number is smaller and round to 2 decimal places - const roundedNumber: number = - Math.abs(number) < 1000 - ? Number(number.toFixed(1)) - : Number(number.toPrecision(4)); + const scaledNumber = number / Math.pow(1000, suffixIndex); - const formattedNumber: string = ( - roundedNumber / Math.pow(1000, suffixIndex) - ).toFixed(1); + // Format the number to one decimal place + let formatted = scaledNumber.toFixed(1); - return formattedNumber + suffixes[suffixIndex]; + // Remove trailing '.0' if present + if (formatted.endsWith(".0")) { + formatted = formatted.slice(0, -2); + } + + return formatted + suffixes[suffixIndex]; } + export function roundToDecimalPlaces( number: number, decimalPlaces: number