diff --git a/src/app/components/building-passport/GoToPlanButton.tsx b/src/app/components/building-passport/GoToPlanButton.tsx new file mode 100644 index 00000000..ef9d9216 --- /dev/null +++ b/src/app/components/building-passport/GoToPlanButton.tsx @@ -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 ( + + ); +} diff --git a/src/app/components/building-passport/RecommendationCard.tsx b/src/app/components/building-passport/RecommendationCard.tsx index 0a9ccc67..650b7f37 100644 --- a/src/app/components/building-passport/RecommendationCard.tsx +++ b/src/app/components/building-passport/RecommendationCard.tsx @@ -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>; costMap: RecommendationMetricMap; setTotalEstimatedCost: Dispatch>; @@ -34,11 +42,11 @@ export default function RecommendationCard({ setExpectedEpcRating: Dispatch>; }) { const defaultComponent = recommendationData.find( - (rec: ComponentRecommendation) => rec.default - ) as ComponentRecommendation; + (rec: Recommendation) => rec.default + ) as Recommendation; const [cardComponent, setCardComponent] = - useState(defaultComponent); + useState(defaultComponent); const [modalIsOpen, setModalIsOpen] = useState(false); @@ -49,7 +57,7 @@ export default function RecommendationCard({ setModalIsOpen(true); }} > -

{componentType}

+

{TitleMap[componentType]}

{cardComponent ? ( cardComponent.description @@ -67,7 +75,7 @@ export default function RecommendationCard({ Estimated Cost: {cardComponent - ? "£" + formatNumber(cardComponent.estimatedCost) + ? "£" + formatNumber(cardComponent?.estimatedCost || 0) : ""} diff --git a/src/app/components/building-passport/RecommendationContainer.tsx b/src/app/components/building-passport/RecommendationContainer.tsx index ae34bb98..cccb5bb3 100644 --- a/src/app/components/building-passport/RecommendationContainer.tsx +++ b/src/app/components/building-passport/RecommendationContainer.tsx @@ -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); - 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({ - 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({ - 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({
- {Object.entries(recommendations).map( + {Object.entries(categorizedRecommendations).map( ([componentType, recommendationData], idx) => { return ( void; - recommendationData: ComponentRecommendation[]; - setCardComponent: Dispatch>; + recommendationData: Recommendation[]; + setCardComponent: Dispatch>; setCostMap: Dispatch>; costMap: RecommendationMetricMap; setTotalEstimatedCost: Dispatch>; @@ -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 ( diff --git a/src/app/components/building-passport/RecommendationTable.tsx b/src/app/components/building-passport/RecommendationTable.tsx index 1bff68a0..97286e8b 100644 --- a/src/app/components/building-passport/RecommendationTable.tsx +++ b/src/app/components/building-passport/RecommendationTable.tsx @@ -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 { +interface DataTableProps { columns: ColumnDef[]; data: T[]; defaultRowIndex: number; @@ -34,7 +34,7 @@ interface DataTableProps { setSaveButtonDisabled: Dispatch>; } -export default function RecommendationTable({ +export default function RecommendationTable({ data, columns, defaultRowIndex, diff --git a/src/app/components/building-passport/RecommendationTableColumns.tsx b/src/app/components/building-passport/RecommendationTableColumns.tsx index 2eab2684..4fb4b367 100644 --- a/src/app/components/building-passport/RecommendationTableColumns.tsx +++ b/src/app/components/building-passport/RecommendationTableColumns.tsx @@ -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[] = [ +const uvalueColumns: ColumnDef[] = [ { accessorKey: "description", header: "Description", diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index 4f51e8be..992c93c6 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -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"; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx new file mode 100644 index 00000000..bbbee524 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/page.tsx @@ -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 ( +
+
Recommendations
+ +
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx new file mode 100644 index 00000000..dd5bd122 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/page.tsx @@ -0,0 +1,99 @@ +import { getPlans, getPropertyMeta } from "../utils"; +import { formatDateTime, formatNumber, sapToEpc } from "@/app/utils"; +import EpcCard from "@/app/components/building-passport/EpcCard"; +import { Card, CardContent, CardHeader } from "@/app/shadcn_components/ui/card"; +import GoToPlanButton from "@/app/components/building-passport/GoToPlanButton"; + +function PlanCard({ + expectedEpcRating, + createdAt, + totalEstimatedCost, + totalSapPoints, + planId, +}: { + expectedEpcRating: string; + createdAt: Date; + totalEstimatedCost: number; + totalSapPoints: number; + planId: string; +}) { + return ( + +
+ +
+
+ + +
+ Total cost: + £{formatNumber(totalEstimatedCost)} +
+
+ Total SAP points: + {totalSapPoints} +
+
+
+
+
+ Created: {formatDateTime(createdAt)} +
+ +
+
+ ); +} + +export default async function RecommendationPlans({ + params, +}: { + params: { slug: string; propertyId: string }; +}) { + const propertyMeta = await getPropertyMeta(params.propertyId); + const plans = await getPlans(params.propertyId); + + // TODO: We don't currently have any visual identification of plans that have been set as default vs not + + return ( +
+
Retrofit Plans
+ +
+ {plans.map((plan, index) => { + const totalEstimatedCost = plan.planRecommendations.reduce( + (acc, rec) => acc + rec.recommendation.estimatedCost, + 0 + ); + const totalSapPoints = plan.planRecommendations.reduce( + (acc, rec) => acc + rec.recommendation.sapPoints, + 0 + ); + + // Placeholder while we return 999 for all sap points + const expectedSapPoints = Math.min( + propertyMeta.currentSapPoints + totalSapPoints, + 100 + ); + + const expectedEpcRating = sapToEpc(expectedSapPoints); + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils.tsx new file mode 100644 index 00000000..d3f77e42 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/utils.tsx @@ -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); +} diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts index 67064c8c..9cfba534 100644 --- a/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/utils.ts @@ -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 { + 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 }; diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts index 09e7047d..3934a0b6 100644 --- a/src/types/recommendations.ts +++ b/src/types/recommendations.ts @@ -1,5 +1,6 @@ export interface RecommendationMetricMap { Walls: number; Floor: number; - Ventilation: number; + // TODO: Implement ventilation + // Ventilation: number; } diff --git a/tailwind.config.js b/tailwind.config.js index 9731b113..f70ada24 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -27,6 +27,7 @@ module.exports = { hoverblue: "#3e4073", brandtan: "#d3b488", hovertan: "#947750", + brandgold: "#f1bb06", brandbrown: "#3d1e05", brandmidblue: "#3943b7", border: "hsl(var(--border))",