mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Implemented recommendations into front end
This commit is contained in:
parent
3b9602b293
commit
e0d252e89a
13 changed files with 255 additions and 49 deletions
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 type {
|
||||
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";
|
||||
|
|
@ -15,7 +15,7 @@ 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,5 +1,5 @@
|
|||
"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";
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -12,16 +12,6 @@ import {
|
|||
import { material } from "./materials";
|
||||
import { InferModel, relations } from "drizzle-orm";
|
||||
|
||||
export interface ComponentRecommendation {
|
||||
id: number;
|
||||
type: string;
|
||||
description: string;
|
||||
estimatedCost: number;
|
||||
default: boolean;
|
||||
newUValue?: number;
|
||||
sapPoints: number;
|
||||
}
|
||||
|
||||
export const recommendation = pgTable("recommendation", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
|
|
@ -120,3 +110,7 @@ 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";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
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);
|
||||
|
||||
console.log(recommendations);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { RecommendationMetricMap } from "@/types/recommendations";
|
||||
|
||||
export function sumRecommendationMetricMap(
|
||||
obj: RecommendationMetricMap
|
||||
): number {
|
||||
// In the recommendations section of the building passport we have the cost map which
|
||||
// contains the costs of the recommendations. We need to sum these costs to display
|
||||
// the total cost of the recommendations
|
||||
return Object.values(obj).reduce((sum, current) => sum + current, 0);
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import { recommendation } from "./../../../../db/schema/recommendations";
|
||||
import { columns } from "./../../components/propertyTableColumns";
|
||||
import {
|
||||
Recommendation,
|
||||
planRecommendations,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { db } from "@/app/db/db";
|
||||
import {
|
||||
Feature,
|
||||
|
|
@ -12,6 +15,31 @@ 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 };
|
||||
|
|
|
|||
|
|
@ -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