diff --git a/src/app/portfolio/[slug]/(portfolio)/plan/page.tsx b/src/app/portfolio/[slug]/(portfolio)/plan/page.tsx
new file mode 100644
index 0000000..74e8b32
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/plan/page.tsx
@@ -0,0 +1,25 @@
+import PortfolioPlanTable from "@/app/components/portfolio/plan/PlanTable";
+import { getPortfolioPlan } from "../../utils";
+import { portfolioPlanColumns } from "@/app/components/portfolio/plan/PlanTableColumns";
+
+export default async function PortfolioPlan({
+ params,
+}: {
+ params: { slug: string };
+}) {
+ const portfolioId = params.slug;
+ const portfolioPlan = await getPortfolioPlan(portfolioId);
+
+ return (
+ <>
+
+ >
+ );
+}
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
new file mode 100644
index 0000000..06d0a90
--- /dev/null
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx
@@ -0,0 +1,22 @@
+import { PropertyMeta } from "@/app/db/schema/property";
+import RecommendationContainer from "@/app/components/building-passport/RecommendationContainer";
+import { getPropertyMeta, getRecommendations } from "../../utils";
+
+export default async function Recommendations({
+ params,
+}: {
+ params: { slug: string; propertyId: string; planId: string };
+}) {
+ const propertyMeta = await getPropertyMeta(params.propertyId);
+ const recommendations = await getRecommendations(params.planId);
+
+ return (
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx
new file mode 100644
index 0000000..dd5bd12
--- /dev/null
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx
@@ -0,0 +1,99 @@
+import { getPlans, getPropertyMeta } from "../utils";
+import { formatDateTime, formatNumber, sapToEpc } from "@/app/utils";
+import EpcCard from "@/app/components/building-passport/EpcCard";
+import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card";
+import GoToPlanButton from "@/app/components/building-passport/GoToPlanButton";
+
+function PlanCard({
+ expectedEpcRating,
+ createdAt,
+ totalEstimatedCost,
+ totalSapPoints,
+ planId,
+}: {
+ expectedEpcRating: string;
+ createdAt: Date;
+ totalEstimatedCost: number;
+ totalSapPoints: number;
+ planId: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ Total cost:
+ £{formatNumber(totalEstimatedCost)}
+
+
+ Total SAP points:
+ {totalSapPoints}
+
+
+
+
+
+ Created: {formatDateTime(createdAt)}
+
+
+
+
+ );
+}
+
+export default async function RecommendationPlans({
+ params,
+}: {
+ params: { slug: string; propertyId: string };
+}) {
+ const propertyMeta = await getPropertyMeta(params.propertyId);
+ const plans = await getPlans(params.propertyId);
+
+ // TODO: We don't currently have any visual identification of plans that have been set as default vs not
+
+ return (
+
+
Retrofit Plans
+
+
+ {plans.map((plan, index) => {
+ const totalEstimatedCost = plan.planRecommendations.reduce(
+ (acc, rec) => acc + rec.recommendation.estimatedCost,
+ 0
+ );
+ const totalSapPoints = plan.planRecommendations.reduce(
+ (acc, rec) => acc + rec.recommendation.sapPoints,
+ 0
+ );
+
+ // Placeholder while we return 999 for all sap points
+ const expectedSapPoints = Math.min(
+ propertyMeta.currentSapPoints + totalSapPoints,
+ 100
+ );
+
+ const expectedEpcRating = sapToEpc(expectedSapPoints);
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/utils.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils.tsx
similarity index 100%
rename from src/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/utils.tsx
rename to src/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils.tsx
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx
index 0b6e031..86e280a 100644
--- a/src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/pre-assessment-report/page.tsx
@@ -21,7 +21,7 @@ import {
function AddressCard({ address }: { address: string | null }) {
// In the future, we might want to use react-wrap-balancer for some of this text
return (
-
+
);
@@ -35,6 +35,9 @@ interface PropertyDetailsCardProps {
};
}
+const rowTitleStyle = "text-gray-100 align-top pb-3";
+const rowValueStyle = "text-gray-100 text-end pr-8 pt-1 align-top pb-3";
+
function PropertyDetailsCard({
conditionReportData,
propertyMeta,
@@ -45,33 +48,29 @@ function PropertyDetailsCard({
.join(" ");
return (
-
-
+
+
-
+
- | Year built: |
-
- {propertyMeta.yearBuilt}
- |
+ Year built: |
+ {propertyMeta.yearBuilt} |
- | Property Type: |
-
- {propertyText}
- |
+ Property Type: |
+ {propertyText} |
- | Total floor area: |
-
+ | Total floor area: |
+
{`${conditionReportData.totalFloorArea} m`}
2
|
- | In conservation area: |
-
+ | In conservation area: |
+
{propertyDetailsSpatial.inConservationArea}
|
@@ -81,26 +80,20 @@ function PropertyDetailsCard({
- | Local Authority: |
-
- {propertyMeta.localAuthority}
- |
+ Local Authority: |
+ {propertyMeta.localAuthority} |
- | Constituency: |
-
- {propertyMeta.constituency}
- |
+ Constituency: |
+ {propertyMeta.constituency} |
- | Tenure |
-
- {propertyMeta.tenure}
- |
+ Tenure |
+ {propertyMeta.tenure} |
- | Number of rooms: |
-
+ | Number of rooms: |
+
{propertyMeta.numberOfRooms || "unkown"}
|
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/page.tsx
deleted file mode 100644
index a47c737..0000000
--- a/src/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/page.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { Recommendation } from "@/app/db/schema/recommendations";
-import { PropertyMeta } from "@/app/db/schema/property";
-import RecommendationContainer from "@/app/components/building-passport/RecommendationContainer";
-import { getPropertyMeta } from "../utils";
-
-export default async function Recommendations({
- params,
-}: {
- params: { slug: string; propertyId: string };
-}) {
- const propertyMeta = await getPropertyMeta(params.propertyId);
-
- const recommendations: Recommendation = {
- Walls: [
- {
- id: 1,
- type: "internal_wall_insulation",
- description: "140mm Mineral Wool internal wall insulation",
- estimatedCost: 9_450,
- default: true,
- newUValue: 0.29,
- sapPoints: 4,
- },
- {
- id: 2,
- type: "internal_wall_insulation",
- description: "30mm Vacuum Insulation Panels wall insulation",
- estimatedCost: 10_135,
- default: false,
- newUValue: 0.28,
- sapPoints: 12,
- },
- {
- id: 3,
- type: "internal_external_wall_insulation",
- description:
- "80mm Mineral Wool External Wall Insulation and 30mm rigid insulation internal wall insulation",
- estimatedCost: 13_450,
- default: false,
- newUValue: 0.25,
- sapPoints: 14,
- },
- ],
- Ventilation: [
- {
- id: 4,
- type: "mechanical_ventilation",
- description: "Two decentralised mechanical ventilation units",
- estimatedCost: 750,
- default: true,
- sapPoints: -2,
- },
- ],
- Floor: [
- {
- id: 5,
- type: "suspended_floor_insulation",
- description: "70mm Rigid insulation foam boards with floor screed",
- estimatedCost: 3_450,
- default: true,
- newUValue: 0.24,
- sapPoints: 7,
- },
- {
- id: 5,
- type: "suspended_floor_insulation",
- description: "90mm Rigid insulation foam boards with floor screed",
- estimatedCost: 4_120,
- default: true,
- newUValue: 0.24,
- sapPoints: 7,
- },
- ],
- };
-
- return (
-
- );
-}
diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts
index 2cb729c..f7c8201 100644
--- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts
+++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts
@@ -1,3 +1,8 @@
+import { recommendation } from "../../../../db/schema/recommendations";
+import {
+ Recommendation,
+ planRecommendations,
+} from "@/app/db/schema/recommendations";
import { db } from "@/app/db/db";
import {
Feature,
@@ -6,9 +11,66 @@ import {
PropertyMeta,
propertyDetailsEpc,
} from "@/app/db/schema/property";
+import { plan, Plan } from "@/app/db/schema/recommendations";
import { getRating } from "@/app/utils";
import { eq } from "drizzle-orm";
+type RecommendationList = {
+ recommendation: Recommendation;
+}[];
+
+export async function getRecommendations(
+ planId: string
+): Promise {
+ const data = (await db.query.planRecommendations.findMany({
+ where: eq(planRecommendations.planId, BigInt(planId)),
+ columns: {},
+ with: {
+ recommendation: true,
+ },
+ })) as RecommendationList;
+
+ if (!data) {
+ throw new Error("Network response was not ok");
+ }
+
+ // unnest the recommendations
+ const recommendations = data.map((item) => item.recommendation);
+
+ return recommendations;
+}
+
+type PlanRelation = Plan & {
+ planRecommendations: {
+ recommendation: { estimatedCost: number; sapPoints: number };
+ }[];
+};
+
+export async function getPlans(propertyId: string): Promise {
+ const data = await db.query.plan.findMany({
+ where: eq(plan.propertyId, BigInt(propertyId)),
+ with: {
+ planRecommendations: {
+ columns: {},
+ with: {
+ recommendation: {
+ columns: {
+ estimatedCost: true,
+ sapPoints: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!data) {
+ throw new Error("Network response was not ok");
+ }
+
+ return data as PlanRelation[];
+}
+
export async function getPropertyMeta(
propertyId: string
): Promise {
diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts
index 2c76306..ac170be 100644
--- a/src/app/portfolio/[slug]/utils.ts
+++ b/src/app/portfolio/[slug]/utils.ts
@@ -1,9 +1,15 @@
-import { eq } from "drizzle-orm";
+import {
+ recommendation,
+ UnnestedRecommendation,
+ PortfolioPlanRecommendation,
+} from "./../../db/schema/recommendations";
+import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/app/db/db";
import { portfolio } from "@/app/db/schema/portfolio";
import { property } from "@/app/db/schema/property";
import type { Portfolio } from "@/app/db/schema/portfolio";
import type { PropertyWithTarget } from "@/app/db/schema/property";
+import { plan, planRecommendations } from "@/app/db/schema/recommendations";
export async function getPortfolio(portfolioId: string): Promise {
const data = await db
@@ -50,3 +56,123 @@ export async function getProperties(
return data;
}
+
+interface UnaggregatedPortfolioPlanRecommendation {
+ quantity: number;
+ quantityUnit: string;
+ estimatedCost: number;
+ materialType: string;
+ propertyId?: string;
+ numberOfProperties?: number; // Optional field to hold the count of unique propertyIds
+}
+
+function aggregateRecommendations(
+ data: UnaggregatedPortfolioPlanRecommendation[]
+): PortfolioPlanRecommendation[] {
+ const grouped: {
+ [key: string]: PortfolioPlanRecommendation & { propertyIds: Set };
+ } = {};
+
+ data.forEach((item) => {
+ // Use the combination of quantityUnit and materialType as a unique key
+ const key = `${item.quantityUnit}_${item.materialType}`;
+
+ if (!grouped[key]) {
+ grouped[key] = {
+ ...item,
+ numberOfProperties: 0, // Initialize to 0
+ propertyIds: item.propertyId ? new Set([item.propertyId]) : new Set(),
+ };
+ } else {
+ grouped[key].quantity += item.quantity;
+ grouped[key].estimatedCost += item.estimatedCost;
+ if (item.propertyId) {
+ grouped[key].propertyIds.add(item.propertyId);
+ }
+ }
+ });
+
+ // Round the results to 2 decimal places and compute uniquePropertyCount
+ for (const key in grouped) {
+ grouped[key].quantity = parseFloat(grouped[key].quantity.toFixed(2));
+ grouped[key].estimatedCost = parseFloat(
+ grouped[key].estimatedCost.toFixed(2)
+ );
+ grouped[key].numberOfProperties = grouped[key].propertyIds.size;
+ delete (grouped[key] as any).propertyIds; // using type assertion to bypass the TS error
+ }
+
+ return Object.values(grouped);
+}
+
+export async function getPortfolioPlan(portfolioId: string) {
+ // To do this we need to do the following:
+ // 1. For the portfolioId, get all of the default plans. This can be done from the plan table
+ // 2. For the plans, get the recommendations. This can be done from the planRecommendation table
+ // 3. For the recommendations get the materials, the quantity and the cost.
+ // 4. For the materials, get the type of material
+
+ // There was some trouble performing all of the relations in a single query so we split it into
+ // three requests (unfortunately)
+
+ const plans = await db.query.plan.findMany({
+ where: and(
+ eq(property.portfolioId, BigInt(portfolioId)),
+ eq(plan.isDefault, true)
+ ),
+ });
+
+ const planIds = plans.map((plan) => plan.id);
+
+ const recommendations = await db.query.planRecommendations.findMany({
+ where: inArray(planRecommendations.planId, planIds),
+ });
+
+ const recommendationIds = recommendations.map(
+ (recommendation) => recommendation.id
+ );
+
+ const data = await db.query.recommendation.findMany({
+ where: and(
+ inArray(recommendation.id, recommendationIds),
+ eq(recommendation.default, true)
+ ),
+ columns: { propertyId: true },
+ with: {
+ recommendationMaterials: {
+ with: {
+ material: {
+ columns: {
+ type: true,
+ },
+ },
+ },
+ columns: {
+ quantity: true,
+ quantityUnit: true,
+ estimatedCost: true,
+ },
+ },
+ },
+ });
+
+ const unnestedRecommendations: UnnestedRecommendation[] = data.reduce(
+ (acc: UnnestedRecommendation[], recommendation) => {
+ const materials = recommendation.recommendationMaterials.map(
+ (material) => ({
+ quantity: material.quantity,
+ quantityUnit: material.quantityUnit,
+ estimatedCost: material.estimatedCost,
+ materialType: material.material.type,
+ propertyId: String(recommendation.propertyId),
+ })
+ );
+ return [...acc, ...materials];
+ },
+ []
+ );
+
+ const aggregated = aggregateRecommendations(unnestedRecommendations);
+
+ return aggregated;
+}
diff --git a/src/app/utils.ts b/src/app/utils.ts
index 35c5c64..8a099ef 100644
--- a/src/app/utils.ts
+++ b/src/app/utils.ts
@@ -47,7 +47,7 @@ export function sapToEpc(sapPoints: number): string {
}
}
-export function formatDateTime(dateTimeString: string): string {
+export function formatDateTime(dateTimeString: string | Date): string {
// Create a new Date object
const dateTime = new Date(dateTimeString);
diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts
index 09e7047..3934a0b 100644
--- a/src/types/recommendations.ts
+++ b/src/types/recommendations.ts
@@ -1,5 +1,6 @@
export interface RecommendationMetricMap {
Walls: number;
Floor: number;
- Ventilation: number;
+ // TODO: Implement ventilation
+ // Ventilation: number;
}
diff --git a/tailwind.config.js b/tailwind.config.js
index 9731b11..f70ada2 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -27,6 +27,7 @@ module.exports = {
hoverblue: "#3e4073",
brandtan: "#d3b488",
hovertan: "#947750",
+ brandgold: "#f1bb06",
brandbrown: "#3d1e05",
brandmidblue: "#3943b7",
border: "hsl(var(--border))",