first implementation of funding into the app

This commit is contained in:
Khalim Conn-Kowlessar 2025-08-21 21:35:08 +00:00
parent 85b38ea43c
commit 6cffb3a3d2
10 changed files with 214 additions and 103 deletions

View file

@ -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 (

View file

@ -14,41 +14,47 @@ export function EnergyEfficiencyImpactCard({
totalSapPoints,
}: EnergyEfficiencyImpactCardProps) {
return (
<div>
<table className="text-left bg-brandblue rounded-md text-gray-100 w-full">
<tbody>
<tr>
<td className="font-medium pl-4 py-2">Energy Efficiency Impact</td>
</tr>
<table className="text-left bg-brandblue rounded-md text-gray-100 w-full border-separate border-spacing-0">
<tbody>
<tr>
<td colSpan={2} className="px-4 pt-4 pb-2 font-semibold text-base">
Energy Efficiency Impact
</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Current EPC Rating:</td>
<td className="font-bold pr-2">
{currentSapPoints + " " + currentEpcRating}
</td>
</tr>
{/* Divider row */}
<tr>
<td colSpan={2}>
<div className="h-[2px] bg-brandbrown mx-4 rounded-sm" />
</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Expected EPC Rating:</td>
<td className="font-bold pr-2">
{Math.floor(expectedSapPoints) + " " + expectedEpcRating}
</td>
</tr>
<tr>
<td className="font-semibold px-4 py-2">Current EPC Rating:</td>
<td className="pr-4 font-bold">
{currentSapPoints} {currentEpcRating}
</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">
Total SAP Points Improvement:
</td>
<td className="pr-2">
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
</td>
</tr>
</tbody>
</table>
</div>
<tr>
<td className="font-semibold px-4 py-2">Expected EPC Rating:</td>
<td className="pr-4 font-bold">
{Math.floor(expectedSapPoints)} {expectedEpcRating}
</td>
</tr>
<tr>
<td className="font-semibold px-4 py-2">Total SAP Points Improvement:</td>
<td className="pr-4 font-bold">
{Math.round((totalSapPoints + Number.EPSILON) * 100) / 100}
</td>
</tr>
</tbody>
</table>
);
}
interface SecondaryEnergyEfficiencyImpactCardProps {
TotalCo2Savings: number;
totalEnergyCostSavings: number;
@ -61,31 +67,39 @@ export function SecondaryEnergyEfficiencyImpactCard({
totalKwhSavings,
}: SecondaryEnergyEfficiencyImpactCardProps) {
return (
<div>
<table className="text-left bg-brandblue rounded-md text-gray-100 w-full">
<tbody>
<tr>
<td style={{ height: "48px" }}></td>
</tr>
<table className="text-left bg-brandblue rounded-md text-gray-100 w-full border-separate border-spacing-0">
<tbody>
<tr>
<td colSpan={2} className="px-4 pt-4 pb-2 font-semibold text-base">
Gains
</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">
CO<sub>2</sub> Reduction
</td>
<td className=" pr-2">{TotalCo2Savings.toFixed(1)}t</td>
</tr>
{/* Divider row */}
<tr>
<td colSpan={2}>
<div className="h-[2px] bg-brandbrown mx-4 rounded-sm" />
</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Energy Savings</td>
<td className="pr-2">{totalKwhSavings.toFixed(0) + "kWh"}</td>
</tr>
<tr>
<td className="font-semibold px-4 py-2">
CO<sub>2</sub> Reduction
</td>
<td className="pr-4 font-bold">{TotalCo2Savings.toFixed(1)}t</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Energy Bill Savings</td>
<td className="pr-2">{"£" + Math.round(totalEnergyCostSavings)}</td>
</tr>
</tbody>
</table>
</div>
<tr>
<td className="font-semibold px-4 py-2">Energy Savings</td>
<td className="pr-4 font-bold">{totalKwhSavings.toFixed(0)}kWh</td>
</tr>
<tr>
<td className="font-semibold px-4 py-2">Energy Bill Savings</td>
<td className="pr-4 font-bold">£{Math.round(totalEnergyCostSavings)}</td>
</tr>
</tbody>
</table>
);
}

View file

@ -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({
<WorksPackageCard
totalEstimatedCost={totalEstimatedCost}
totalLabourDays={totalLabourDays}
totalFunding={totalFunding}
/>
<EnergyEfficiencyImpactCard
@ -346,6 +357,7 @@ export default function RecommendationContainer({
currentValuation={propertyMeta.currentValuation}
valuationIncreaseLowerBound={planMeta.valuationIncreaseLowerBound}
valuationIncreaseUpperBound={planMeta.valuationIncreaseUpperBound}
funding={funding[0]}
/>
</div>

View file

@ -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<FundingSummaryProps> = ({ 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 (
<Card className="w-full max-w-sm bg-brandblue border-none shadow-none">
<CardContent className="p-4 flex flex-col items-center text-center space-y-2">
{message ? (
<p className="text-sm text-muted-foreground">{message}</p>
) : (
<>
<div className="flex items-center gap-2 text-xl font-semibold uppercase tracking-wide text-brandbrown">
<span>{scheme}</span>
<PiggyBank className="h-5 w-5 text-brandbrown" />
</div>
<p className="text-sm text-white max-w-xs">
Click below to learn more about available funding options.
</p>
<Button
variant="ghost"
size="sm"
onClick={onSeeMore}
className="text-brandbrown hover:bg-brandbrown hover:text-brandblue"
>
See More
</Button>
</>
)}
</CardContent>
</Card>
)
}
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 (
<div className="col-span-1 md:col-span-2 lg:col-span-3 w-full p-4 bg-brandblue rounded-lg shadow-md grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex flex-col items-center text-center">
<span className="text-gray-100 text-lg">Current Value</span>
<span className="text-3xl font-bold text-brandgold mt-1">
£{currentValuation.toLocaleString()}
<div className="flex flex-col items-center justify-center text-center h-full space-y-1">
<span className="text-gray-100 text-lg">Current Property Value</span>
<span className="text-3xl font-bold text-brandbrown">
£{formatNumber(currentValuation)}
</span>
</div>
<div className="flex flex-col items-center text-center">
<div className="flex flex-col items-center justify-center text-center space-y-1">
<span className="text-gray-100 text-lg">After Retrofit Valuation</span>
<div className="text-2xl text-brandgold mt-1">
£{lowerBoundValuation.toLocaleString()} - £
{upperBoundValuation.toLocaleString()}
<div className="text-2xl text-brandbrown font-bold">
£{formatNumber(lowerBoundValuation)} - £{formatNumber(upperBoundValuation)}
</div>
<span className="text-sm text-gray-100 mt-1">
Estimated improvement: £
{valuationIncreaseLowerBound?.toLocaleString()} - £
{valuationIncreaseUpperBound?.toLocaleString()}
<span className="text-gray-100 text-lg">Estimated improvement:</span>
<span className="text-brandbrown font-bold">
£{formatNumber(valuationIncreaseLowerBound || 0)} - £{formatNumber(valuationIncreaseUpperBound || 0)}
</span>
</div>
<div className="flex flex-col items-center text-center">
<span className="text-gray-100 text-lg mb-2">Funding Options</span>
<BrandButton
label="See More"
onClick={openFundingModal}
backgroundColor="brandlightblue"
/>
</div>
<FundingSummary
scheme={funding.scheme}
onSeeMore={openFundingModal}
/>
</div>
);
}

View file

@ -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 (
<table className="text-left bg-brandblue rounded-md text-gray-100">
<table className="text-left bg-brandblue rounded-md text-gray-100 w-full border-separate border-spacing-0">
<tbody>
<tr>
<td className="font-medium pl-4 py-2">Works Package</td>
<td colSpan={2} className="px-4 pt-4 pb-2 font-semibold text-base">
Works Package
</td>
</tr>
{/* Divider row */}
<tr>
<td colSpan={2}>
<div className="h-[2px] bg-brandbrown mx-4 rounded-sm" />
</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Total Cost:</td>
<td className="pr-2">{"£" + formatNumber(totalEstimatedCost)}</td>
<td className="font-semibold px-4 py-2">Total Cost:</td>
<td className="pr-4 font-bold">{"£" + formatNumber(totalEstimatedCost)}</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Trades required:</td>
<td className="pr-2">{"1-2"}</td>
<td className="font-semibold px-4 py-2">Total Funding:</td>
<td className="pr-4 font-bold">{"£" + formatNumber(totalFunding || 0)}</td>
</tr>
<tr>
<td className="font-medium pl-4 py-2">Estimated Duration:</td>
<td className="pr-2">{convertDaysToWorkingWeeks(totalLabourDays)}</td>
<td className="font-semibold px-4 py-2">Estimated Duration:</td>
<td className="pr-4 font-bold">{convertDaysToWorkingWeeks(totalLabourDays)}</td>
</tr>
</tbody>
</table>
);
}

View file

@ -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;
export type FundingPackage = typeof fundingPackage.$inferSelect;
export type FundingPackageMeasure = typeof fundingPackageMeasures.$inferSelect;
export type FundingPackageWithMeasures = FundingPackage & {
fundingPackageMeasures: FundingPackageMeasure[];
};

View file

@ -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],
}),
}));

View file

@ -21,6 +21,7 @@ export default async function Recommendations(
recommendations={recommendations}
propertyMeta={propertyMeta}
planMeta={planMeta}
funding={funding}
/>
</div>
);

View file

@ -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<FundingPackage[]> {
export async function getPlanFunding(planId: string): Promise<FundingPackageWithMeasures[]> {
const data = await db.query.fundingPackage.findMany({
where: eq(fundingPackage.planId, BigInt(planId)),
with: {
fundingPackageMeasures: true,
},
});
if (!data) {

View file

@ -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