diff --git a/drizzle.config.ts b/drizzle.config.ts index dfba60b..6738659 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,7 +1,22 @@ +import * as dotenv from "dotenv"; import type { Config } from "drizzle-kit"; +dotenv.config({ path: ".env.local" }); + +const isProduction = process.env.VERCEL_ENV === "production"; + export default { schema: "./src/app/db/schema/*", out: "./src/app/db/migrations", dialect: "postgresql", + dbCredentials: { + host: process.env.DB_HOST!, + port: Number(process.env.DB_PORT!), + user: process.env.DB_USERNAME!, + password: process.env.DB_PASSWORD!, + database: process.env.DB_NAME!, + ssl: isProduction + ? true // strict SSL for prod + : { rejectUnauthorized: false }, // allow self-signed in dev/preview + } } satisfies Config; diff --git a/package.json b/package.json index 3112b7b..8459da7 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "lint": "next lint", "test:e2e:open": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"", "test:e2e:run": "cypress run", - "migration:generate": "drizzle-kit generate:pg --config=drizzle.config.ts", - "migration:push": "tsx src/app/db/migrate.ts", + "migration:generate": "drizzle-kit generate", + "migration:push": "drizzle-kit push", "create_user": "tsx src/app/db/create_user.ts" }, "dependencies": { diff --git a/src/app/api/portfolio/scenario/[scenarioId]/route.ts b/src/app/api/portfolio/scenario/[scenarioId]/route.ts index 2e3c4aa..085b5f4 100644 --- a/src/app/api/portfolio/scenario/[scenarioId]/route.ts +++ b/src/app/api/portfolio/scenario/[scenarioId]/route.ts @@ -14,6 +14,8 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena .select({ name: scenario.name, cost: scenario.cost, + funding: scenario.funding, + contingency: scenario.contingency, epcBreakdownPostRetrofit: scenario.epcBreakdownPostRetrofit, numberOfProperties: scenario.numberOfProperties, nUnitsToRetrofit: scenario.nUnitsToRetrofit, @@ -99,7 +101,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena ], }, { - title: "Cost (£)", + title: "Cost of works (£)", scenarios: [ { scenarioName: scenarioName, @@ -108,10 +110,34 @@ export async function GET(request: NextRequest, props: { params: Promise<{ scena ], }, { - title: "Cost (£)/unit", + title: "Cost of works (£)/unit", scenarios: [ { scenarioName: scenarioName, data: data[0].costPerUnit || "" }, ], + }, + { + title: "Funding (£)", + scenarios: [ + { scenarioName: scenarioName, data: "£" + formatNumber(data[0].funding || 0) }, + ], + }, + { + title: "Funding (£)/unit", + scenarios: [ + { scenarioName: scenarioName, data: "£" + formatNumber((data[0].funding || 0) / (data[0].nUnitsToRetrofit || 1)) }, + ], + }, + { + title: "Contingency (£)", + scenarios: [ + { scenarioName: scenarioName, data: "£" + formatNumber(data[0].contingency || 0) }, + ], + }, + { + title: "Contingency (£)/unit", + scenarios: [ + { scenarioName: scenarioName, data: "£" + formatNumber((data[0].contingency || 0) / (data[0].nUnitsToRetrofit || 1)) }, + ], }, { title: "£ per CO2 reduction", diff --git a/src/app/api/upload/csv/route.ts b/src/app/api/upload/csv/route.ts index f140a90..d142ff6 100644 --- a/src/app/api/upload/csv/route.ts +++ b/src/app/api/upload/csv/route.ts @@ -31,8 +31,9 @@ export async function POST(request: NextRequest) { secretAccessKey: process.env.PRESIGN_AWS_SECRET_KEY, }); - const { userId, portfolioId, fileKey } = validatedBody; + const { fileKey } = validatedBody; + console.log("Making presigned URL") // Presigned url is valid for 5 minutes const preSignedUrl = await s3.getSignedUrl("putObject", { Bucket: process.env.RETROFIT_PLAN_INPUT_BUCKET_NAME, @@ -40,6 +41,7 @@ export async function POST(request: NextRequest) { ContentType: "text/csv", Expires: 5 * 60, }); + console.log("preSignedUrl:", preSignedUrl) return new NextResponse(JSON.stringify({ url: preSignedUrl }), { status: 200, 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/RecommendationCard.tsx b/src/app/components/building-passport/RecommendationCard.tsx index 4633e38..c331321 100644 --- a/src/app/components/building-passport/RecommendationCard.tsx +++ b/src/app/components/building-passport/RecommendationCard.tsx @@ -136,7 +136,7 @@ export default function RecommendationCard({ const optionTextClassName = alreadyInstalled ? "text-brandgold" - : "text-blue-500 hover:text-blue-300"; + : "text-brandbrown hover:text-blue-300"; const optionsText = alreadyInstalled ? "Already installed" @@ -173,22 +173,16 @@ export default function RecommendationCard({ Estimated Cost: - + {cardComponent ? "£" + formatNumber(cardComponent?.estimatedCost || 0) : ""} - {cardComponent.newUValue && ( - - New U-Value: - {cardComponent.newUValue} - - )} {cardComponent.sapPoints != null && ( SAP Points: - + {cardComponent.sapPoints < 0.1 && cardComponent.type !== "mechanical_ventilation" ? "Negligible" diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index d6c5bd8..af9dc11 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; @@ -322,10 +332,11 @@ export default function RecommendationContainer({ return ( <> -
+
- +
{Object.entries(categorizedRecommendations).map( diff --git a/src/app/components/building-passport/ValuationImpactComponent.tsx b/src/app/components/building-passport/ValuationImpactComponent.tsx index 17ce12a..f1856a0 100644 --- a/src/app/components/building-passport/ValuationImpactComponent.tsx +++ b/src/app/components/building-passport/ValuationImpactComponent.tsx @@ -1,16 +1,73 @@ "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' +import { Dialog, Transition } from '@headlessui/react' +import { Fragment } from 'react' +import { FundingPackageMeasure } from "@/app/db/schema/funding"; +import { set } from "cypress/types/lodash"; + + +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. +

+ {!message && ( + + )} + + )} +
+
+ ) +} 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 +86,113 @@ 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 - -
+ + setFundingModalIsOpen(false)} + scheme={funding.scheme} + fundingPackageMeasures={funding.fundingPackageMeasures} + />
); } + + +type FundingSummaryModalProps = { + isOpen: boolean + closeModal: () => void + scheme?: string | null; + fundingPackageMeasures: FundingPackageMeasure[] +} + +export function FundingSummaryModal({ + isOpen, + closeModal, + scheme, + fundingPackageMeasures, +}: FundingSummaryModalProps) { + return ( + + + +
+ + +
+
+ + + + + {scheme?.toUpperCase() || 'Funding Details'} + + +

+ This package has been optimised to meet the eligibility criteria of the {scheme?.toUpperCase()} funding scheme. Measures have been selected to maximise the property’s improvement while minimising cost and ensuring compliance. +

+ +
+

Included Measures

+
    + {fundingPackageMeasures.map((measure) => ( +
  • + {measure.measure.replace(/_/g, ' ')} +
  • + ))} +
+
+ +
+ +
+
+
+
+
+
+
+ ) +} 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/components/portfolio/SummaryBox.tsx b/src/app/components/portfolio/SummaryBox.tsx index c92cebb..5c6b949 100644 --- a/src/app/components/portfolio/SummaryBox.tsx +++ b/src/app/components/portfolio/SummaryBox.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; -import { convertDaysToWorkingWeeks, formatNumber } from "@/app/utils"; +import { formatNumber } from "@/app/utils"; + interface SummaryBoxProps { scenarios: Array<{ @@ -8,6 +9,8 @@ interface SummaryBoxProps { name: string; budget: number | null; totalCost: number | null; + funding: number | null; + contingency: number | null; co2EquivalentSavings: number | null; propertyValuationIncrease: number | null; energySavings: number | null; @@ -33,6 +36,15 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) { const [totalCostFormatted, setTotalCostFormatted] = useState( formatMoney(defaultScenario.totalCost) ); + const [funding, setFunding] = useState( + formatMoney(defaultScenario.funding) + ); + const [netCost, setNetCost] = useState( + formatMoney((defaultScenario.totalCost || 0) - (defaultScenario.funding || 0)) + ); + const [contingency, setContingency] = useState( + formatMoney(defaultScenario.contingency) + ); const [totalValueIncreaseFormatted, setTotalValueIncreaseFormatted] = useState(formatMoney(defaultScenario.propertyValuationIncrease)); const [energyCostSavingsFormatted, setEnergyCostSavingsFormatted] = useState( @@ -56,6 +68,13 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) { setBudgetFormatted(formatBudget(selectedScenario.budget)); setTotalCostFormatted(formatMoney(selectedScenario.totalCost)); + setFunding(formatMoney(selectedScenario.funding)); + setNetCost( + formatMoney( + (selectedScenario.totalCost || 0) - (selectedScenario.funding || 0) + ) + ); + setContingency(formatMoney(selectedScenario.contingency)); setTotalValueIncreaseFormatted( formatMoney(selectedScenario.propertyValuationIncrease) ); @@ -119,18 +138,36 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) { - - + + - - + + + + + + + + + + + + + - +
Total Budget{budgetFormatted}Budget{budgetFormatted}
Total Cost + Cost {totalCostFormatted}
Funding + {funding} +
Cost after funding + {netCost} +
Contingency + {contingency} +
Total properties{numProperties}{numProperties}
@@ -149,13 +186,13 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) { {" "} Savings - + {co2EquivalentSavingsFormatted} Annual Energy Savings - + {energySavingsFormatted} @@ -164,19 +201,19 @@ function SummaryBox({ scenarios, numProperties }: SummaryBoxProps) {

- Financial Impact + Bills and Property Valuation

- - diff --git a/src/app/components/portfolio/summary/SummaryTable.tsx b/src/app/components/portfolio/summary/SummaryTable.tsx index b60a499..14c90bf 100644 --- a/src/app/components/portfolio/summary/SummaryTable.tsx +++ b/src/app/components/portfolio/summary/SummaryTable.tsx @@ -184,7 +184,7 @@ const SummaryTable = ({
Annual Energy Bill Reduction + {energyCostSavingsFormatted}
Total Value Increase + {totalValueIncreaseFormatted}