mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
first implementation of funding into the app
This commit is contained in:
parent
85b38ea43c
commit
6cffb3a3d2
10 changed files with 214 additions and 103 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
|
@ -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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export default async function Recommendations(
|
|||
recommendations={recommendations}
|
||||
propertyMeta={propertyMeta}
|
||||
planMeta={planMeta}
|
||||
funding={funding}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue