mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #14 from Hestia-Homes/recommendations-ui
Recommendations UI
This commit is contained in:
commit
4b686abd30
32 changed files with 4664 additions and 198 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
24
src/app/components/building-passport/GoToPlanButton.tsx
Normal file
24
src/app/components/building-passport/GoToPlanButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
83
src/app/components/portfolio/plan/PlanTable.tsx
Normal file
83
src/app/components/portfolio/plan/PlanTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/components/portfolio/plan/PlanTableColumns.tsx
Normal file
62
src/app/components/portfolio/plan/PlanTableColumns.tsx
Normal 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>;
|
||||
},
|
||||
},
|
||||
];
|
||||
6
src/app/db/migrations/0035_perfect_tenebrous.sql
Normal file
6
src/app/db/migrations/0035_perfect_tenebrous.sql
Normal 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 $$;
|
||||
9
src/app/db/migrations/0036_real_stryfe.sql
Normal file
9
src/app/db/migrations/0036_real_stryfe.sql
Normal 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;
|
||||
1
src/app/db/migrations/0037_awesome_harry_osborn.sql
Normal file
1
src/app/db/migrations/0037_awesome_harry_osborn.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "unit_quantity" ADD VALUE 'm2';
|
||||
1272
src/app/db/migrations/meta/0035_snapshot.json
Normal file
1272
src/app/db/migrations/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1296
src/app/db/migrations/meta/0036_snapshot.json
Normal file
1296
src/app/db/migrations/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1296
src/app/db/migrations/meta/0037_snapshot.json
Normal file
1296
src/app/db/migrations/meta/0037_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
31
src/app/portfolio/[slug]/(portfolio)/layout.tsx
Normal file
31
src/app/portfolio/[slug]/(portfolio)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
25
src/app/portfolio/[slug]/(portfolio)/plan/page.tsx
Normal file
25
src/app/portfolio/[slug]/(portfolio)/plan/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface RecommendationMetricMap {
|
||||
Walls: number;
|
||||
Floor: number;
|
||||
Ventilation: number;
|
||||
// TODO: Implement ventilation
|
||||
// Ventilation: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ module.exports = {
|
|||
hoverblue: "#3e4073",
|
||||
brandtan: "#d3b488",
|
||||
hovertan: "#947750",
|
||||
brandgold: "#f1bb06",
|
||||
brandbrown: "#3d1e05",
|
||||
brandmidblue: "#3943b7",
|
||||
border: "hsl(var(--border))",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue