Added scenarios mechanism for summary page

This commit is contained in:
Khalim Conn-Kowlessar 2024-07-31 11:27:01 +01:00
parent 097d80c69e
commit 24527703be
6 changed files with 181 additions and 139 deletions

View file

@ -1,46 +1,46 @@
import { portfolio } from "@/app/db/schema/portfolio";
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/app/db/db";
import { z } from "zod";
import { formatNumber } from "@/app/utils";
import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils";
import { eq } from "drizzle-orm";
import { scenario } from "@/app/db/schema/recommendations";
export async function GET(
request: NextRequest,
{ params }: { params: { portfolioId: string } }
{ params }: { params: { scenarioId: string } }
) {
const portfolioId = params.portfolioId;
const scenarioId = params.scenarioId;
const data = await db
.select({
name: portfolio.name,
cost: portfolio.cost,
epcBreakdownPostRetrofit: portfolio.epcBreakdownPostRetrofit,
numberOfProperties: portfolio.numberOfProperties,
nUnitsToRetrofit: portfolio.nUnitsToRetrofit,
co2PerUnitPostRetrofit: portfolio.co2PerUnitPostRetrofit,
energyBillPerUnitPostRetrofit: portfolio.energyBillPerUnitPostRetrofit,
name: scenario.name,
cost: scenario.cost,
epcBreakdownPostRetrofit: scenario.epcBreakdownPostRetrofit,
numberOfProperties: scenario.numberOfProperties,
nUnitsToRetrofit: scenario.nUnitsToRetrofit,
co2PerUnitPostRetrofit: scenario.co2PerUnitPostRetrofit,
energyBillPerUnitPostRetrofit: scenario.energyBillPerUnitPostRetrofit,
energyConsumptionPerUnitPostRetrofit:
portfolio.energyConsumptionPerUnitPostRetrofit,
valuationImprovementPerUnit: portfolio.valuationImprovementPerUnit,
costPerUnit: portfolio.costPerUnit,
costPerCo2Saved: portfolio.costPerCo2Saved,
costPerSapPoint: portfolio.costPerSapPoint,
valuationReturnOnInvestment: portfolio.valuationReturnOnInvestment,
scenario.energyConsumptionPerUnitPostRetrofit,
valuationImprovementPerUnit: scenario.valuationImprovementPerUnit,
costPerUnit: scenario.costPerUnit,
costPerCo2Saved: scenario.costPerCo2Saved,
costPerSapPoint: scenario.costPerSapPoint,
valuationReturnOnInvestment: scenario.valuationReturnOnInvestment,
})
.from(portfolio)
.where(eq(portfolio.id, BigInt(portfolioId)));
.from(scenario)
.where(eq(scenario.id, BigInt(scenarioId)));
if (data.length === 0) {
throw new Error("Portfolio not found");
throw new Error("Scenario not found");
}
if (data.length > 1) {
throw new Error("More than one portfolio found");
throw new Error("More than one scenario found");
}
const portfolioName = data[0].name;
const scenarioName = data[0].name || "Default";
// Format the data we need for the overview
const output: DataItem[] = [
@ -48,7 +48,7 @@ export async function GET(
title: "EPCs",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: JSON.parse(
data[0].epcBreakdownPostRetrofit || "[]"
) as ChartData[],
@ -59,7 +59,7 @@ export async function GET(
title: "# Units",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: String(data[0].numberOfProperties) || "",
},
],
@ -68,7 +68,7 @@ export async function GET(
title: "# Units to retrofit",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: String(data[0].nUnitsToRetrofit) || "",
},
],
@ -77,7 +77,7 @@ export async function GET(
title: "Co2/unit",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: data[0].co2PerUnitPostRetrofit || "",
},
],
@ -86,7 +86,7 @@ export async function GET(
title: "Annual energy bill/unit",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: data[0].energyBillPerUnitPostRetrofit || "",
},
],
@ -95,7 +95,7 @@ export async function GET(
title: "Annual Energy consumption (kWh)/unit",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: data[0].energyConsumptionPerUnitPostRetrofit || "",
},
],
@ -104,7 +104,7 @@ export async function GET(
title: "Cost (£)",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: "£" + formatNumber(data[0].cost || 0),
},
],
@ -112,19 +112,19 @@ export async function GET(
{
title: "Cost (£)/unit",
scenarios: [
{ scenarioName: portfolioName, data: data[0].costPerUnit || "" },
{ scenarioName: scenarioName, data: data[0].costPerUnit || "" },
],
},
{
title: "£ per CO2 reduction",
scenarios: [
{ scenarioName: portfolioName, data: data[0].costPerCo2Saved || "" },
{ scenarioName: scenarioName, data: data[0].costPerCo2Saved || "" },
],
},
{
title: "£ per SAP point",
scenarios: [
{ scenarioName: portfolioName, data: data[0].costPerSapPoint || "" },
{ scenarioName: scenarioName, data: data[0].costPerSapPoint || "" },
],
},
{
@ -132,7 +132,7 @@ export async function GET(
"Potential valuation improvement/unit - highly indicative EPC effect*",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: data[0].valuationImprovementPerUnit || "",
},
],
@ -142,7 +142,7 @@ export async function GET(
"Potential valuation return on investment - highly indicative EPC effect*",
scenarios: [
{
scenarioName: portfolioName,
scenarioName: scenarioName,
data: data[0].valuationReturnOnInvestment || "",
},
],

View file

@ -4,18 +4,18 @@ import { Fragment, useState } from "react";
const SelectComparisonModal = ({
isOpen,
setIsOpen,
userPortfolios,
scenarios,
onAddColumn,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
userPortfolios: { name: string; id: bigint }[];
scenarios: { name: string | null; id: bigint }[];
onAddColumn: (columnName: string) => void;
}) => {
const [selectedPortfolio, setSelectedPortfolio] = useState("");
const [selectedScenario, setSelectedScenario] = useState("");
const addColumn = () => {
onAddColumn(selectedPortfolio);
onAddColumn(selectedScenario);
setIsOpen(false);
};
@ -65,15 +65,15 @@ const SelectComparisonModal = ({
<div className="mt-2">
<select
className="w-full p-2 border border-gray-300 rounded-md"
value={selectedPortfolio}
onChange={(e) => setSelectedPortfolio(e.target.value)}
value={selectedScenario}
onChange={(e) => setSelectedScenario(e.target.value)}
>
{userPortfolios.map((portfolio) => (
{scenarios.map((scenario) => (
<option
key={portfolio.id.toString()}
value={String(portfolio.id)}
key={scenario.id.toString()}
value={String(scenario.id)}
>
{portfolio.name}
{scenario.name}
</option>
))}
</select>

View file

@ -20,12 +20,12 @@ import EpcBarChart from "./EpcBarChart";
import { DataItem, ChartData } from "@/app/portfolio/[slug]/utils";
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query";
export async function getComparsionOverviewPortfolioData(
portfolioId: string
export async function getComparsionOverviewScenarioData(
scenarioId: string
): Promise<Promise<DataItem[]>> {
const url = process.env.URL || "" + `/api/portfolio/summary/${portfolioId}`;
const url = process.env.URL || "" + `/api/portfolio/scenario/${scenarioId}`;
// This is the client side counterpart to getOverviewPortfolioData
// This is the client side counterpart to getOverviewScenarioData
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
@ -37,21 +37,21 @@ export async function getComparsionOverviewPortfolioData(
return response.json();
}
interface UseFetchPortfolioSummaryProps {
portfolioId: string; // Assuming portfolioId can be null to handle cases before it's available
interface UseFetchScenarioSummaryProps {
scenarioId: string; // Assuming scenarioId can be null to handle cases before it's available
onSuccess: (data: DataItem[]) => void;
options?: Omit<UseQueryOptions<DataItem[], Error>, "queryKey" | "queryFn">;
}
export const useFetchPortfolioSummary = ({
portfolioId,
export const useFetchScenarioSummary = ({
scenarioId,
onSuccess,
}: UseFetchPortfolioSummaryProps) => {
}: UseFetchScenarioSummaryProps) => {
return useQuery({
queryKey: ["portfolioSummary", portfolioId],
queryFn: () => getComparsionOverviewPortfolioData(portfolioId as string), // Ensure non-null assertion or handle potential null inside the function
queryKey: ["scenarioSummary", scenarioId],
queryFn: () => getComparsionOverviewScenarioData(scenarioId as string), // Ensure non-null assertion or handle potential null inside the function
onSuccess,
enabled: portfolioId !== "no-id", // Ensure the query only runs when portfolioId is not null
enabled: scenarioId !== "no-id", // Ensure the query only runs when portfolioId is not null
});
};
@ -64,10 +64,10 @@ const shouldHighlightRow = (title: string) => {
const SummaryTable = ({
data,
userPortfolios,
scenarios,
}: {
data: DataItem[];
userPortfolios: { name: string; id: bigint }[];
scenarios: { name: string | null; id: bigint }[];
}) => {
// TODO: Right now a user can ad multiple comparisons of the same column, we should prevent this
@ -117,16 +117,15 @@ const SummaryTable = ({
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedPortfolioId, setSelectedPortfolioId] =
useState<string>("no-id");
const [selectedScenarioId, setSelectedScenarioId] = useState<string>("no-id");
// Fetch and handle data when a new portfolio is selected
// Fetch and handle data when a new scenario is selected
const {
isLoading,
error,
data: fetchedData,
} = useFetchPortfolioSummary({
portfolioId: selectedPortfolioId,
} = useFetchScenarioSummary({
scenarioId: selectedScenarioId,
onSuccess: (fetchedData: DataItem[]) => {
const updatedData = managedData.map((item) => ({
...item,
@ -171,15 +170,17 @@ const SummaryTable = ({
<div className="my-8">
<button
onClick={() => setIsModalOpen(true)}
className="mb-4 p-2 bg-brandgold text-white rounded"
className="mb-4 p-2 bg-brandgold text-white rounded disabled:opacity-50"
// If scenarios is empty, we disable this button
disabled={scenarios.length === 0}
>
Add Comparison
</button>
<SelectComparisonModal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen}
userPortfolios={userPortfolios}
onAddColumn={(id: string) => setSelectedPortfolioId(id)}
scenarios={scenarios}
onAddColumn={(id: string) => setSelectedScenarioId(id)}
/>
<div className="overflow-x-auto shadow-md sm:rounded-lg">

View file

@ -1,5 +1,8 @@
import SummaryTable from "@/app/components/portfolio/summary/SummaryTable";
import { getUserPortfolios, getOverviewPortfolioData } from "../../utils";
import {
getNonDefaultPortfolioScenarios,
getOverviewPortfolioData,
} from "../../utils";
export default async function PortfolioSummary({
params,
@ -11,14 +14,23 @@ export default async function PortfolioSummary({
// Retrieve all of the names and ids of the portfolios, attributed to this user
// Get user id from the session
const userPortfolios = await getUserPortfolios(portfolioId);
const scenarios = await getNonDefaultPortfolioScenarios(portfolioId);
return (
<div className="container mx-auto px-4">
<h1 className="text-3xl text-gray-700 font-bold my-4">Summary</h1>
<SummaryTable data={data} userPortfolios={userPortfolios} />
<SummaryTable data={data} scenarios={scenarios} />
<div className="text-sm text-gray-600 mb-5">
* Valuation improvement is based on these studies from
<a
className="ml-1 underline"
href="https://www.rightmove.co.uk/news/articles/property-news/average-uk-energy-bills-how-to-save-money/#:~:text=A%20'green%20premium'%20that%20could,Rightmove%20Greener%20Homes%20Report%202023)."
target="_blank"
rel="noopener noreferrer"
>
Rightmove
</a>
,
<a
className="ml-1 underline"
href="https://www.knightfrank.com/research/article/2022-10-11-improving-your-epc-rating-could-increase-your-homes-value-by-up-to-20"

View file

@ -1,7 +1,7 @@
import { formatNumber } from "@/app/utils";
import { and, ConsoleLogWriter, eq, inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/app/db/db";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import { portfolio } from "@/app/db/schema/portfolio";
import { property } from "@/app/db/schema/property";
import type { Portfolio } from "@/app/db/schema/portfolio";
import type { PropertyWithRelations } from "@/app/db/schema/property";
@ -71,33 +71,70 @@ export async function getOverviewPortfolioData(
): Promise<DataItem[]> {
// Keep just the columns we need for the overview
const data = await db
// We firstly attempt to retrieve the data from the scenarios table, starting with the default
// scenario. If the default scenario doesn't exist, we'll use the data from the
// portfolio
let data = await db
.select({
name: portfolio.name,
cost: portfolio.cost,
epcBreakdownPreRetrofit: portfolio.epcBreakdownPreRetrofit,
epcBreakdownPostRetrofit: portfolio.epcBreakdownPostRetrofit,
numberOfProperties: portfolio.numberOfProperties,
nUnitsToRetrofit: portfolio.nUnitsToRetrofit,
co2PerUnitPreRetrofit: portfolio.co2PerUnitPreRetrofit,
co2PerUnitPostRetrofit: portfolio.co2PerUnitPostRetrofit,
energyBillPerUnitPreRetrofit: portfolio.energyBillPerUnitPreRetrofit,
energyBillPerUnitPostRetrofit: portfolio.energyBillPerUnitPostRetrofit,
name: scenario.name,
cost: scenario.cost,
epcBreakdownPreRetrofit: scenario.epcBreakdownPreRetrofit,
epcBreakdownPostRetrofit: scenario.epcBreakdownPostRetrofit,
numberOfProperties: scenario.numberOfProperties,
nUnitsToRetrofit: scenario.nUnitsToRetrofit,
co2PerUnitPreRetrofit: scenario.co2PerUnitPreRetrofit,
co2PerUnitPostRetrofit: scenario.co2PerUnitPostRetrofit,
energyBillPerUnitPreRetrofit: scenario.energyBillPerUnitPreRetrofit,
energyBillPerUnitPostRetrofit: scenario.energyBillPerUnitPostRetrofit,
energyConsumptionPerUnitPreRetrofit:
portfolio.energyConsumptionPerUnitPreRetrofit,
scenario.energyConsumptionPerUnitPreRetrofit,
energyConsumptionPerUnitPostRetrofit:
portfolio.energyConsumptionPerUnitPostRetrofit,
valuationImprovementPerUnit: portfolio.valuationImprovementPerUnit,
costPerUnit: portfolio.costPerUnit,
costPerCo2Saved: portfolio.costPerCo2Saved,
costPerSapPoint: portfolio.costPerSapPoint,
valuationReturnOnInvestment: portfolio.valuationReturnOnInvestment,
scenario.energyConsumptionPerUnitPostRetrofit,
valuationImprovementPerUnit: scenario.valuationImprovementPerUnit,
costPerUnit: scenario.costPerUnit,
costPerCo2Saved: scenario.costPerCo2Saved,
costPerSapPoint: scenario.costPerSapPoint,
valuationReturnOnInvestment: scenario.valuationReturnOnInvestment,
})
.from(portfolio)
.where(eq(portfolio.id, BigInt(portfolioId)));
.from(scenario)
.where(
and(
eq(scenario.portfolioId, BigInt(portfolioId)),
eq(scenario.isDefault, true)
)
);
if (data.length === 0) {
throw new Error("Portfolio not found");
// If we have no data, we fetch it from the scenarios. This will facilitate this for older portfolios
data = await db
.select({
name: portfolio.name,
cost: portfolio.cost,
epcBreakdownPreRetrofit: portfolio.epcBreakdownPreRetrofit,
epcBreakdownPostRetrofit: portfolio.epcBreakdownPostRetrofit,
numberOfProperties: portfolio.numberOfProperties,
nUnitsToRetrofit: portfolio.nUnitsToRetrofit,
co2PerUnitPreRetrofit: portfolio.co2PerUnitPreRetrofit,
co2PerUnitPostRetrofit: portfolio.co2PerUnitPostRetrofit,
energyBillPerUnitPreRetrofit: portfolio.energyBillPerUnitPreRetrofit,
energyBillPerUnitPostRetrofit: portfolio.energyBillPerUnitPostRetrofit,
energyConsumptionPerUnitPreRetrofit:
portfolio.energyConsumptionPerUnitPreRetrofit,
energyConsumptionPerUnitPostRetrofit:
portfolio.energyConsumptionPerUnitPostRetrofit,
valuationImprovementPerUnit: portfolio.valuationImprovementPerUnit,
costPerUnit: portfolio.costPerUnit,
costPerCo2Saved: portfolio.costPerCo2Saved,
costPerSapPoint: portfolio.costPerSapPoint,
valuationReturnOnInvestment: portfolio.valuationReturnOnInvestment,
})
.from(portfolio)
.where(eq(portfolio.id, BigInt(portfolioId)));
// If we still have no data, we throw and error
if (data.length === 0) {
throw new Error("Portfolio not found");
}
}
if (data.length > 1) {
@ -118,7 +155,7 @@ export async function getOverviewPortfolioData(
) as ChartData[],
},
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: JSON.parse(
data[0].epcBreakdownPostRetrofit || "[]"
) as ChartData[],
@ -133,7 +170,7 @@ export async function getOverviewPortfolioData(
data: String(data[0].numberOfProperties) || "",
},
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: String(data[0].numberOfProperties) || "",
},
],
@ -143,7 +180,7 @@ export async function getOverviewPortfolioData(
scenarios: [
{ scenarioName: "Today", data: "-" },
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: String(data[0].nUnitsToRetrofit) || "",
},
],
@ -153,7 +190,7 @@ export async function getOverviewPortfolioData(
scenarios: [
{ scenarioName: "Today", data: data[0].co2PerUnitPreRetrofit || "" },
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: data[0].co2PerUnitPostRetrofit || "",
},
],
@ -166,7 +203,7 @@ export async function getOverviewPortfolioData(
data: data[0].energyBillPerUnitPreRetrofit || "",
},
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: data[0].energyBillPerUnitPostRetrofit || "",
},
],
@ -179,7 +216,7 @@ export async function getOverviewPortfolioData(
data: data[0].energyConsumptionPerUnitPreRetrofit || "",
},
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: data[0].energyConsumptionPerUnitPostRetrofit || "",
},
],
@ -189,7 +226,7 @@ export async function getOverviewPortfolioData(
scenarios: [
{ scenarioName: "Today", data: "" },
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: "£" + formatNumber(data[0].cost || 0),
},
],
@ -198,21 +235,30 @@ export async function getOverviewPortfolioData(
title: "Cost (£)/unit",
scenarios: [
{ scenarioName: "Today", data: "" },
{ scenarioName: portfolioName, data: data[0].costPerUnit || "" },
{
scenarioName: portfolioName || "Default",
data: data[0].costPerUnit || "",
},
],
},
{
title: "£ per CO2 reduction",
scenarios: [
{ scenarioName: "Today", data: "" },
{ scenarioName: portfolioName, data: data[0].costPerCo2Saved || "" },
{
scenarioName: portfolioName || "Default",
data: data[0].costPerCo2Saved || "",
},
],
},
{
title: "£ per SAP point",
scenarios: [
{ scenarioName: "Today", data: "" },
{ scenarioName: portfolioName, data: data[0].costPerSapPoint || "" },
{
scenarioName: portfolioName || "Default",
data: data[0].costPerSapPoint || "",
},
],
},
{
@ -221,7 +267,7 @@ export async function getOverviewPortfolioData(
scenarios: [
{ scenarioName: "Today", data: "" },
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: data[0].valuationImprovementPerUnit || "",
},
],
@ -232,7 +278,7 @@ export async function getOverviewPortfolioData(
scenarios: [
{ scenarioName: "Today", data: "" },
{
scenarioName: portfolioName,
scenarioName: portfolioName || "Default",
data: data[0].valuationReturnOnInvestment || "",
},
],
@ -242,42 +288,25 @@ export async function getOverviewPortfolioData(
return output;
}
export async function getUserPortfolios(
export async function getNonDefaultPortfolioScenarios(
portfolioId: string
): Promise<{ id: bigint; name: string }[]> {
// This function will retrieve all of the portfolio ids attributed to a user
// This can be done by querying the portfolioUsers table
): Promise<{ id: bigint; name: string | null }[]> {
// For a given portfolio Id, we want to retrieve all of the scenarios that are attributed to this portfolio
// We exclude the default scenario
const scenarios = await db
.select({
id: scenario.id,
name: scenario.name,
})
.from(scenario)
.where(
and(
eq(scenario.portfolioId, BigInt(portfolioId)),
eq(scenario.isDefault, false)
)
);
const portfolioUser = await db.query.portfolioUsers.findFirst({
where: eq(portfolioUsers.portfolioId, BigInt(portfolioId)),
});
if (!portfolioUser) {
throw new Error("Portfolio not found");
}
const userId = portfolioUser?.userId;
if (!userId) {
throw new Error("User not found");
}
// We now filter portfolioUsers by the userId
const data = await db.query.portfolioUsers.findMany({
where: eq(portfolioUsers.userId, userId),
with: {
portfolio: {
// Make sure this matches the relation name defined in relations setup
columns: {
id: true,
name: true,
},
},
},
});
// Extract the portfolio data from the nested object
return data.map((item) => item.portfolio);
return scenarios;
}
export async function getProperties(

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
// "target": "es5",
"target": "ESNext",
"target": "es5",
// "target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,