Merge pull request #14 from Hestia-Homes/recommendations-ui

Recommendations UI
This commit is contained in:
KhalimCK 2023-08-21 19:45:16 +01:00 committed by GitHub
commit 4b686abd30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 4664 additions and 198 deletions

View file

@ -1,18 +1,35 @@
export default function EpcCard({
epcRating,
fullMargin = true,
expected = false,
}: {
epcRating: string;
fullMargin: boolean;
expected?: boolean;
}) {
let marginClass = "";
if (fullMargin) {
marginClass = "mx-auto";
}
let title;
let bgStyling;
if (expected) {
title = "Expected Energy Rating";
bgStyling = "bg-green-600";
} else {
title = "Energy Rating";
bgStyling = "bg-brandblue";
}
return (
<div className="flex flex-col items-center p-8 shadow rounded-md max-w-xl justify-start text-gray-100 bg-brandblue">
<div className="text-xl font-bold mb-4 text-center">Energy Rating</div>
<div
className={
"flex flex-col items-center p-4 shadow rounded-md max-w-xl justify-start text-gray-100 " +
bgStyling
}
>
<div className="text-xl font-bold mb-4 text-center">{title}</div>
<div className="text-6xl font-bold">{epcRating}</div>
</div>
);

View file

@ -0,0 +1,24 @@
"use client";
import { Button } from "@/app/shadcn_components/ui/button";
import { ChevronRight } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
export default function GoToPlanButton({ planId }: { planId: string }) {
const router = useRouter();
const pathname = usePathname();
function handleOnClick() {
router.push(`${pathname}/${planId}`);
}
return (
<Button
className="bg-brandblue hover:bg-hoverblue mb-2"
onClick={handleOnClick}
>
Go to plan
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
);
}

View file

@ -1,5 +1,8 @@
"use client";
import { ComponentRecommendation } from "@/app/db/schema/recommendations";
import {
Recommendation,
RecommendationType,
} from "@/app/db/schema/recommendations";
import { Dispatch, SetStateAction, useState } from "react";
import { formatNumber } from "@/app/utils";
import { RecommendationMetricMap } from "@/types/recommendations";
@ -10,6 +13,11 @@ const selectionStyling =
const noSelectionStyling =
"shadow active:shadow active:bg-brandmidblue w-full border rounded p-4 cursor-pointer text-gray-300 bg-white hover:bg-hoverblue hover:text-gray-100 transition-colors rounded-md flex flex-col justify-start";
const TitleMap = {
wall_insulation: "Wall Insulation",
floor_insulation: "Floor Insulation",
};
export default function RecommendationCard({
componentType,
recommendationData,
@ -22,8 +30,8 @@ export default function RecommendationCard({
currentSapPoints,
setExpectedEpcRating,
}: {
componentType: string;
recommendationData: ComponentRecommendation[];
componentType: RecommendationType;
recommendationData: Recommendation[];
setCostMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
costMap: RecommendationMetricMap;
setTotalEstimatedCost: Dispatch<SetStateAction<number>>;
@ -34,11 +42,11 @@ export default function RecommendationCard({
setExpectedEpcRating: Dispatch<SetStateAction<string>>;
}) {
const defaultComponent = recommendationData.find(
(rec: ComponentRecommendation) => rec.default
) as ComponentRecommendation;
(rec: Recommendation) => rec.default
) as Recommendation;
const [cardComponent, setCardComponent] =
useState<ComponentRecommendation>(defaultComponent);
useState<Recommendation>(defaultComponent);
const [modalIsOpen, setModalIsOpen] = useState(false);
@ -49,7 +57,7 @@ export default function RecommendationCard({
setModalIsOpen(true);
}}
>
<h2 className="font-bold mb-4 text-lg">{componentType}</h2>
<h2 className="font-bold mb-4 text-lg">{TitleMap[componentType]}</h2>
<div className="mb-3">
{cardComponent ? (
cardComponent.description
@ -67,7 +75,7 @@ export default function RecommendationCard({
<td className="font-medium">Estimated Cost:</td>
<td>
{cardComponent
? "£" + formatNumber(cardComponent.estimatedCost)
? "£" + formatNumber(cardComponent?.estimatedCost || 0)
: ""}
</td>
</tr>

View file

@ -1,8 +1,8 @@
"use client";
import {
ComponentRecommendation,
Recommendation,
RecommendationType,
} from "@/app/db/schema/recommendations";
import RecommendationCard from "./RecommendationCard";
import RecommendationCostSummaryCard from "./RecommendationCostSummaryCard";
@ -10,12 +10,12 @@ import { Separator } from "@/app/shadcn_components/ui/separator";
import { PropertyMeta } from "@/app/db/schema/property";
import { sapToEpc } from "@/app/utils";
import { useState } from "react";
import { sumRecommendationMetricMap } from "@/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/utils";
import { sumRecommendationMetricMap } from "@/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils";
import { RecommendationMetricMap } from "@/types/recommendations";
import RecommendationEpcSummaryCard from "./RecommendationEpcSummaryCard";
interface RecommendationContainerProps {
recommendations: Recommendation;
recommendations: Recommendation[];
propertyMeta: PropertyMeta;
}
@ -23,28 +23,40 @@ export default function RecommendationContainer({
recommendations,
propertyMeta,
}: RecommendationContainerProps) {
const defaultWallsRecommendations = recommendations.Walls?.find(
(rec: ComponentRecommendation) => rec.default
) || { estimatedCost: 0, sapPoints: 0 };
const categorizedRecommendations = recommendations.reduce((acc, curr) => {
const typeKey = curr.type as RecommendationType;
const defaultFloorRecommendations = recommendations.Floor?.find(
(rec: ComponentRecommendation) => rec.default
) || { estimatedCost: 0, sapPoints: 0 };
if (!acc[typeKey]) {
acc[typeKey] = [];
}
acc[typeKey].push(curr);
return acc;
}, {} as Record<RecommendationType, (typeof recommendations)[0][]>);
const defaultVentiliationRecommendations = recommendations.Ventilation?.find(
(rec: ComponentRecommendation) => rec.default
) || { estimatedCost: 0, sapPoints: 0 };
const defaultWallsRecommendations =
categorizedRecommendations.wall_insulation.find(
(rec: Recommendation) => rec.default
) || { estimatedCost: 0, sapPoints: 0 };
const defaultFloorRecommendations =
categorizedRecommendations.floor_insulation?.find(
(rec: Recommendation) => rec.default
) || { estimatedCost: 0, sapPoints: 0 };
// const defaultVentiliationRecommendations = recommendations.Ventilation?.find(
// (rec: ComponentRecommendation) => rec.default
// ) || { estimatedCost: 0, sapPoints: 0 };
const [costMap, setCostMap] = useState<RecommendationMetricMap>({
Walls: defaultWallsRecommendations.estimatedCost,
Floor: defaultFloorRecommendations.estimatedCost,
Ventilation: defaultVentiliationRecommendations.estimatedCost,
Walls: defaultWallsRecommendations?.estimatedCost || 0,
Floor: defaultFloorRecommendations?.estimatedCost || 0,
// Ventilation: defaultVentiliationRecommendations?.estimatedCost || 0,
});
const [sapMap, setSapMap] = useState<RecommendationMetricMap>({
Walls: defaultWallsRecommendations.sapPoints,
Floor: defaultFloorRecommendations.sapPoints,
Ventilation: defaultVentiliationRecommendations.sapPoints,
Walls: defaultWallsRecommendations?.sapPoints || 0,
Floor: defaultFloorRecommendations.sapPoints || 0,
// Ventilation: defaultVentiliationRecommendations.sapPoints,
});
const [totalEstimatedCost, setTotalEstimatedCost] = useState(
@ -58,7 +70,8 @@ export default function RecommendationContainer({
const currentEpcRating = propertyMeta.currentEpcRating;
const currentSapPoints = propertyMeta.currentSapPoints;
const expectedSapPoints = currentSapPoints + totalSapPoints;
//TODO: Use Math.min while we have dummy SAP points
const expectedSapPoints = Math.min(currentSapPoints + totalSapPoints, 100);
const [expectedEpcRating, setExpectedEpcRating] = useState(
sapToEpc(expectedSapPoints)
);
@ -79,12 +92,13 @@ export default function RecommendationContainer({
<Separator className="mb-4" />
<div className="flex flex-col grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch">
{Object.entries(recommendations).map(
{Object.entries(categorizedRecommendations).map(
([componentType, recommendationData], idx) => {
return (
<RecommendationCard
key={idx}
componentType={componentType}
// entires means we loose the typing on the key
componentType={componentType as RecommendationType}
recommendationData={recommendationData}
setCostMap={setCostMap}
costMap={costMap}

View file

@ -1,10 +1,10 @@
"use client";
import { ComponentRecommendation } from "@/app/db/schema/recommendations";
import { Recommendation } from "@/app/db/schema/recommendations";
import { Dispatch, Fragment, SetStateAction, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import RecommendationTable from "@/app/components/building-passport/RecommendationTable";
import { RecommendationMetricMap } from "@/types/recommendations";
import { sumRecommendationMetricMap } from "@/app/portfolio/[slug]/building-passport/[propertyId]/recommendations/utils";
import { sumRecommendationMetricMap } from "@/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils";
import uvalueColumns from "./RecommendationTableColumns";
import { sapToEpc } from "@/app/utils";
@ -12,8 +12,8 @@ interface RecommendationModalProps {
title: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
recommendationData: ComponentRecommendation[];
setCardComponent: Dispatch<SetStateAction<ComponentRecommendation>>;
recommendationData: Recommendation[];
setCardComponent: Dispatch<SetStateAction<Recommendation>>;
setCostMap: Dispatch<SetStateAction<RecommendationMetricMap>>;
costMap: RecommendationMetricMap;
setTotalEstimatedCost: Dispatch<SetStateAction<number>>;
@ -88,8 +88,11 @@ export default function RecommendationModal({
const newSapImporvement = sumRecommendationMetricMap(newSapMap);
setTotalSapPoints(newSapImporvement);
// TODO: While we have placeholder SAP points, constrain to 100
const newSapPoints = Math.min(currentSapPoints + newSapImporvement, 100);
// update the expected EPC rating
setExpectedEpcRating(sapToEpc(currentSapPoints + newSapImporvement));
setExpectedEpcRating(sapToEpc(newSapPoints));
}
return (

View file

@ -16,10 +16,10 @@ import {
TableRow,
} from "@/app/shadcn_components/ui/table";
import { ComponentRecommendation } from "@/app/db/schema/recommendations";
import { Recommendation } from "@/app/db/schema/recommendations";
import { Dispatch, SetStateAction, useEffect } from "react";
interface DataTableProps<T extends ComponentRecommendation> {
interface DataTableProps<T extends Recommendation> {
columns: ColumnDef<T>[];
data: T[];
defaultRowIndex: number;
@ -34,7 +34,7 @@ interface DataTableProps<T extends ComponentRecommendation> {
setSaveButtonDisabled: Dispatch<SetStateAction<boolean>>;
}
export default function RecommendationTable<T extends ComponentRecommendation>({
export default function RecommendationTable<T extends Recommendation>({
data,
columns,
defaultRowIndex,

View file

@ -1,9 +1,9 @@
import { ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
import { formatNumber } from "@/app/utils";
import { ComponentRecommendation } from "@/app/db/schema/recommendations";
import { Recommendation } from "@/app/db/schema/recommendations";
const uvalueColumns: ColumnDef<ComponentRecommendation>[] = [
const uvalueColumns: ColumnDef<Recommendation>[] = [
{
accessorKey: "description",
header: "Description",

View file

@ -46,10 +46,10 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
const recommendationsButton = (
<NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/recommendations`}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/plans`}
>
<WrenchScrewdriverIcon className="h-4 w-4 mr-2" />
Retrofit Recommendations
Retrofit Plans
</NavigationMenuLink>
);
@ -66,13 +66,13 @@ export function Toolbar({ propertyId, portfolioId }: ToolbarProps) {
<NavigationMenuList>
{preAssessmentReportButton}
{recommendationsButton}
<NavigationMenuLink
{/* <NavigationMenuLink
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
href={`/portfolio/${portfolioId}/building-passport/${propertyId}/plan-optimiser`}
>
<LightBulbIcon className="h-4 w-4 mr-2" />
Plan optimiser
</NavigationMenuLink>
</NavigationMenuLink> */}
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickSettings}

View file

@ -1,6 +1,10 @@
"use client";
import { Cog6ToothIcon, CalculatorIcon } from "@heroicons/react/24/outline";
import {
Cog6ToothIcon,
CalculatorIcon,
BuildingOfficeIcon,
} from "@heroicons/react/24/outline";
import {
NavigationMenu,
NavigationMenuItem,
@ -10,6 +14,7 @@ import AddNewDropDown from "./AddNew";
import { cva } from "class-variance-authority";
import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface ToolbarProps {
portfolioId: string;
@ -20,12 +25,18 @@ const navigationMenuTriggerStyle = cva(
);
export function Toolbar({ portfolioId }: ToolbarProps) {
const router = useRouter();
function handleClickSettings() {
console.log("Settings were clicked, implement me");
}
function handleClickPortfolioPlan() {
console.log("Opt Plan was clicked, implement me");
router.push(`/portfolio/${portfolioId}/plan`);
}
function handleClickPortfolio() {
router.push(`/portfolio/${portfolioId}`);
}
const [modalIsOpen, setModalIsOpen] = useState(false);
@ -33,6 +44,14 @@ export function Toolbar({ portfolioId }: ToolbarProps) {
return (
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickPortfolio}
>
<BuildingOfficeIcon className="h-4 w-4 mr-2" />
Portfolio
</NavigationMenuItem>
<NavigationMenuItem
className={navigationMenuTriggerStyle() + " ml-3 mr-2"}
onClick={handleClickPortfolioPlan}

View file

@ -0,0 +1,83 @@
"use client";
import {
flexRender,
getCoreRowModel,
useReactTable,
ColumnDef,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/app/shadcn_components/ui/table";
import { PortfolioPlanRecommendation } from "@/app/db/schema/recommendations";
interface DataTableProps<T extends PortfolioPlanRecommendation> {
columns: ColumnDef<T>[];
data: T[];
}
export default function PortfolioPlanTable<
T extends PortfolioPlanRecommendation
>({ data, columns }: DataTableProps<T>) {
// Initialise the table
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View file

@ -0,0 +1,62 @@
"use client";
import { PortfolioPlanRecommendation } from "@/app/db/schema/recommendations";
import { formatNumber } from "@/app/utils";
import { ColumnDef } from "@tanstack/react-table";
const formattedQuantity = {
m2: "m²",
};
const formattedMeasure = {
internal_wall_insulation: "Internal Wall Insulation",
external_wall_insulation: "External Wall Insulation",
};
export const portfolioPlanColumns: ColumnDef<PortfolioPlanRecommendation>[] = [
{
accessorKey: "materialType",
header: "Retrofit Measure",
cell: ({ row }) => {
return (
<div>
{
formattedMeasure[
row.original.materialType as keyof typeof formattedMeasure
]
}
</div>
);
},
},
{
accessorKey: "numberOfProperties",
header: "Number of properties",
cell: ({ row }) => {
return (
<div className="text-center">{row.original.numberOfProperties}</div>
);
},
},
{
accessorKey: "quantity",
header: "Quantity required",
cell: ({ row }) => {
return (
<div>
{row.original.quantity +
formattedQuantity[
row.original.quantityUnit as keyof typeof formattedQuantity
]}
</div>
);
},
},
{
accessorKey: "estimatedCost",
header: "Estimated Cost",
cell: ({ row }) => {
return <div>{"£" + formatNumber(row.original.estimatedCost)}</div>;
},
},
];

View file

@ -0,0 +1,6 @@
ALTER TABLE "plan" ADD COLUMN "property_id" bigint NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "plan" ADD CONSTRAINT "plan_property_id_property_id_fk" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,9 @@
DO $$ BEGIN
CREATE TYPE "unit_quantity" AS ENUM('meters_squared');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "recommendation_materials" ADD COLUMN "quantity" real NOT NULL;--> statement-breakpoint
ALTER TABLE "recommendation_materials" ADD COLUMN "quantity_unit" "unit_quantity" NOT NULL;--> statement-breakpoint
ALTER TABLE "recommendation_materials" ADD COLUMN "estimated_cost" real NOT NULL;

View file

@ -0,0 +1 @@
ALTER TYPE "unit_quantity" ADD VALUE 'm2';

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -246,6 +246,27 @@
"when": 1692012985856,
"tag": "0034_wandering_nick_fury",
"breakpoints": true
},
{
"idx": 35,
"version": "5",
"when": 1692183485471,
"tag": "0035_perfect_tenebrous",
"breakpoints": true
},
{
"idx": 36,
"version": "5",
"when": 1692623295104,
"tag": "0036_real_stryfe",
"breakpoints": true
},
{
"idx": 37,
"version": "5",
"when": 1692623678223,
"tag": "0037_awesome_harry_osborn",
"breakpoints": true
}
]
}

View file

@ -8,24 +8,10 @@ import {
real,
boolean,
bigint,
pgEnum,
} from "drizzle-orm/pg-core";
import { material } from "./materials";
export interface ComponentRecommendation {
id: number;
type: string;
description: string;
estimatedCost: number;
default: boolean;
newUValue?: number;
sapPoints: number;
}
export interface Recommendation {
Walls?: ComponentRecommendation[];
Ventilation?: ComponentRecommendation[];
Floor?: ComponentRecommendation[];
}
import { InferModel, relations } from "drizzle-orm";
export const recommendation = pgTable("recommendation", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
@ -51,6 +37,9 @@ export const recommendation = pgTable("recommendation", {
totalWorkHours: real("total_work_hours"),
});
export const unitQuantity: [string, ...string[]] = ["m2"];
export const unitQuantityEnum = pgEnum("unit_quantity", unitQuantity);
export const recommendationMaterials = pgTable("recommendation_materials", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
recommendationId: bigint("recommendation_id", {
@ -63,6 +52,9 @@ export const recommendationMaterials = pgTable("recommendation_materials", {
.references(() => material.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
depth: real("depth"),
quantity: real("quantity").notNull(),
quantityUnit: unitQuantityEnum("quantity_unit").notNull(),
estimatedCost: real("estimated_cost").notNull(),
});
export const plan = pgTable("plan", {
@ -70,6 +62,9 @@ export const plan = pgTable("plan", {
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id),
propertyId: bigint("property_id", { mode: "bigint" })
.notNull()
.references(() => property.id),
createdAt: timestamp("created_at").notNull().defaultNow(),
isDefault: boolean("is_default").notNull(),
});
@ -85,3 +80,84 @@ export const planRecommendations = pgTable("plan_recommendations", {
.notNull()
.references(() => recommendation.id),
});
// create a one to many relation to map a plan to the details in the underlying recommendation
// create a many to many map from a plan to a recommendation
// A recommendation can be in multiple plans and therefore we have a many to many relationship between
// plan and recommendations. This relationship is facilitated by the planRecommdnations table
export const planRelations = relations(plan, ({ many }) => ({
planRecommendations: many(planRecommendations),
}));
export const planRecommendationsRelations = relations(
planRecommendations,
({ one }) => ({
plan: one(plan, {
fields: [planRecommendations.planId],
references: [plan.id],
}),
recommendation: one(recommendation, {
fields: [planRecommendations.recommendationId],
references: [recommendation.id],
}),
})
);
// We construct a relationship between a recommendation and recommendationMaterials
// On recommendationMaterial will map to a single recommendation
// Define the relationships for the recommendation table
export const recommendationRelations = relations(
recommendation,
({ many }) => ({
recommendationMaterials: many(recommendationMaterials),
})
);
// Define the relationships for the material table
export const materialRelations = relations(material, ({ many }) => ({
recommendationMaterials: many(recommendationMaterials),
}));
// Define the relationships for the recommendationMaterials table
export const recommendationMaterialsRelations = relations(
recommendationMaterials,
({ one }) => ({
recommendation: one(recommendation, {
fields: [recommendationMaterials.recommendationId],
references: [recommendation.id],
}),
material: one(material, {
fields: [recommendationMaterials.materialId],
references: [material.id],
}),
})
);
export type Plan = InferModel<typeof plan, "select">;
export type Recommendation = InferModel<typeof recommendation, "select">;
export type PlanRecommendations = InferModel<
typeof planRecommendations,
"select"
>;
// We allow recommendation types to be a string however we'll set up a typing for it here to control
// the types we expect
export type RecommendationType = "wall_insulation" | "floor_insulation";
export type UnnestedRecommendation = {
quantity: number;
quantityUnit: string;
estimatedCost: number;
materialType: string;
propertyId: string;
};
export interface PortfolioPlanRecommendation {
quantity: number;
quantityUnit: string;
estimatedCost: number;
materialType: string;
numberOfProperties: number;
}

View file

@ -0,0 +1,31 @@
import { Toolbar } from "@/app/components/portfolio/Toolbar";
import { getPortfolio } from "../utils";
export default async function PortfolioLayout({
children, // will be a page or nested layout
params,
}: {
children: React.ReactNode;
params: { slug: string; propertyId: string };
}) {
const portfolioId = params.slug;
const { name: portfolioName } = await getPortfolio(portfolioId);
return (
<section>
<div className="flex justify-center">
<h1 className="text-3xl text-gray-700 font-bold mt-3 mb-4">
{portfolioName}
</h1>
</div>
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-2">
<Toolbar portfolioId={portfolioId} />
</div>
</div>
</div>
{children}
</section>
);
}

View file

@ -1,5 +1,5 @@
import { HomeIcon } from "@heroicons/react/24/outline";
import { getPortfolio, getProperties } from "./utils";
import { getPortfolio, getProperties } from "../utils";
import { Toolbar } from "@/app/components/portfolio/Toolbar";
import DataTable from "@/app/portfolio/[slug]/components/propertyTable";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
@ -187,12 +187,8 @@ export default async function Page({
}) {
// This page is served from the server so we can make calls to the database
// This is temp until we retrieve this data from the frontend
// TODO: Update the objects to contains objective + any other required information
const portfolioId = params.slug;
const {
name: portfolioName,
budget,
cost: totalCost,
co2EquivalentSavings,
@ -212,18 +208,6 @@ export default async function Page({
return (
<>
<div className="flex justify-center">
<h1 className="text-3xl text-gray-700 font-bold mt-3 mb-4">
{portfolioName}
</h1>
</div>
<div className="flex justify-center">
<div className="grid grid-cols-8 w-full max-w-8xl">
<div className="col-span-12 justify-center bg-gray-50 py-2">
<Toolbar portfolioId={portfolioId} />
</div>
</div>
</div>
<div className="flex justify-center">
<div className="grid grid-cols-11 w-full max-w-8xl h-screen">
<div className="col-span-3 flex-col">

View file

@ -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 (
<>
<div className="flex justify-center mt-8">
{
<PortfolioPlanTable
data={portfolioPlan}
columns={portfolioPlanColumns}
/>
}
</div>
</>
);
}

View file

@ -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 (
<div className="leading-loose tracking-wider">
<div className="flex py-8 text-lg">Recommendations</div>
<RecommendationContainer
recommendations={recommendations}
propertyMeta={propertyMeta}
/>
</div>
);
}

View file

@ -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 (
<Card className="flex items-start">
<div className="flex-none w-1/5">
<EpcCard
epcRating={expectedEpcRating}
fullMargin={true}
expected={true}
/>
</div>
<div className="flex-grow pl-4 flex flex-col justify-between">
<CardHeader className="flex justify-end items-center"></CardHeader>
<CardContent>
<div className="flex justify-between mb-2">
<span>Total cost:</span>
<span>£{formatNumber(totalEstimatedCost)}</span>
</div>
<div className="flex justify-between">
<span>Total SAP points:</span>
<span>{totalSapPoints}</span>
</div>
</CardContent>
</div>
<div className="flex flex-col justify-between mr-2 self-stretch w-1/5">
<div className="text-xs text-gray-400 p-0 text-end">
Created: {formatDateTime(createdAt)}
</div>
<GoToPlanButton planId={planId} />
</div>
</Card>
);
}
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 (
<div className="leading-loose tracking-wider">
<div className="flex py-8 text-lg">Retrofit Plans</div>
<div className="max-w-3xl">
{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 (
<PlanCard
key={index}
expectedEpcRating={expectedEpcRating}
createdAt={plan.createdAt}
totalEstimatedCost={totalEstimatedCost}
totalSapPoints={totalSapPoints}
planId={String(plan.id)}
/>
);
})}
</div>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col items-center p-8 shadow rounded-md max-w-xl mx-auto justify-start text-gray-100 bg-brandblue">
<div className="flex flex-col items-center p-4 shadow rounded-md max-w-xl mx-auto justify-start text-gray-100 bg-brandblue">
<div className="text-2xl font-bold max-w-l">{address}</div>
</div>
);
@ -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 (
<div className="w-full flex flex-col items-center p-5 shadow rounded-md justify-start text-gray-100 bg-brandblue">
<div className="grid grid-cols-2 gap-8 text-m w-full h-full">
<div className="w-full flex flex-col items-center p-4 shadow rounded-md justify-start text-gray-100 bg-brandblue">
<div className="grid grid-cols-2 gap-8 text-m w-full h-full text-sm">
<div className="border-r">
<table className="w-full">
<table className="w-full ">
<tbody>
<tr>
<td className="text-gray-100 ">Year built:</td>
<td className="text-gray-100 text-end pr-8 py-1">
{propertyMeta.yearBuilt}
</td>
<td className={rowTitleStyle}>Year built:</td>
<td className={rowValueStyle}>{propertyMeta.yearBuilt}</td>
</tr>
<tr>
<td className="text-gray-100 ">Property Type:</td>
<td className="text-gray-100 text-end pr-8 py-1">
{propertyText}
</td>
<td className={rowTitleStyle}>Property Type:</td>
<td className={rowValueStyle}>{propertyText}</td>
</tr>
<tr>
<td className="text-gray-100 ">Total floor area:</td>
<td className="text-gray-100 text-end pr-8 py-1">
<td className={rowTitleStyle}>Total floor area:</td>
<td className={rowValueStyle}>
{`${conditionReportData.totalFloorArea} m`}
<sup>2</sup>
</td>
</tr>
<tr>
<td className="text-gray-100 ">In conservation area:</td>
<td className="text-gray-100 text-end pr-8 py-1">
<td className={rowTitleStyle}>In conservation area:</td>
<td className={rowValueStyle}>
{propertyDetailsSpatial.inConservationArea}
</td>
</tr>
@ -81,26 +80,20 @@ function PropertyDetailsCard({
<table className="w-full">
<tbody>
<tr>
<td className="text-gray-100 ">Local Authority:</td>
<td className="text-gray-100 text-end pr-8 py-1">
{propertyMeta.localAuthority}
</td>
<td className={rowTitleStyle}>Local Authority:</td>
<td className={rowValueStyle}>{propertyMeta.localAuthority}</td>
</tr>
<tr>
<td className="text-gray-100 ">Constituency:</td>
<td className="text-gray-100 text-end pr-8 py-1">
{propertyMeta.constituency}
</td>
<td className={rowTitleStyle}>Constituency:</td>
<td className={rowValueStyle}>{propertyMeta.constituency}</td>
</tr>
<tr>
<td className="text-gray-100 ">Tenure</td>
<td className="text-gray-100 text-end pr-8 py-1">
{propertyMeta.tenure}
</td>
<td className={rowTitleStyle}>Tenure</td>
<td className={rowValueStyle}>{propertyMeta.tenure}</td>
</tr>
<tr>
<td className="text-gray-100 ">Number of rooms:</td>
<td className="text-gray-100 text-end pr-8 py-1">
<td className={rowTitleStyle}>Number of rooms:</td>
<td className={rowValueStyle}>
{propertyMeta.numberOfRooms || "unkown"}
</td>
</tr>

View file

@ -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 (
<div className="leading-loose tracking-wider">
<div className="flex py-8 text-lg">Recommendations</div>
<RecommendationContainer
recommendations={recommendations}
propertyMeta={propertyMeta}
/>
</div>
);
}

View file

@ -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<Recommendation[]> {
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<PlanRelation[]> {
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<PropertyMeta> {

View file

@ -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<Portfolio> {
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<string> };
} = {};
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;
}

View file

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

View file

@ -1,5 +1,6 @@
export interface RecommendationMetricMap {
Walls: number;
Floor: number;
Ventilation: number;
// TODO: Implement ventilation
// Ventilation: number;
}

View file

@ -27,6 +27,7 @@ module.exports = {
hoverblue: "#3e4073",
brandtan: "#d3b488",
hovertan: "#947750",
brandgold: "#f1bb06",
brandbrown: "#3d1e05",
brandmidblue: "#3943b7",
border: "hsl(var(--border))",