Implemented recommendations into front end

This commit is contained in:
Khalim Conn-Kowlessar 2023-08-16 18:55:42 +01:00
parent 3b9602b293
commit e0d252e89a
13 changed files with 255 additions and 49 deletions

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 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>

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";
@ -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}

View file

@ -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 (

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

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

View file

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

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

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

View file

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

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))",