From 63396147204856ebd89f882cc5a8e7e3b8fae6b7 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 11 Apr 2026 08:05:41 +0000 Subject: [PATCH] added missing files --- src/app/actions/recommendations.ts | 235 ++++++++++++++++++ .../building-passport/AlternativesDrawer.tsx | 110 ++++++++ .../building-passport/StickyImpactBar.tsx | 167 +++++++++++++ .../building-passport/recommendation-types.ts | 3 + .../[propertyId]/plans/[planId]/loading.tsx | 102 ++++++++ 5 files changed, 617 insertions(+) create mode 100644 src/app/actions/recommendations.ts create mode 100644 src/app/components/building-passport/AlternativesDrawer.tsx create mode 100644 src/app/components/building-passport/StickyImpactBar.tsx create mode 100644 src/app/components/building-passport/recommendation-types.ts create mode 100644 src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts new file mode 100644 index 00000000..8f4167a3 --- /dev/null +++ b/src/app/actions/recommendations.ts @@ -0,0 +1,235 @@ +"use server"; +import { db } from "@/app/db/db"; +import { + recommendation, + planRecommendations, + plan, +} from "@/app/db/schema/recommendations"; +import { eq, inArray, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +// Maps specific recommendation types to their parent category. +// Mirrors the categorisation in RecommendationContainer. +const typeToCategoryMap: Record = { + internal_wall_insulation: "wall_insulation", + external_wall_insulation: "wall_insulation", + cavity_wall_insulation: "wall_insulation", + loft_insulation: "roof_insulation", + room_roof_insulation: "roof_insulation", + flat_roof_insulation: "roof_insulation", + suspended_floor_insulation: "floor_insulation", + solid_floor_insulation: "floor_insulation", + exposed_floor_insulation: "floor_insulation", +}; + +function getCategoryTypes(type: string): string[] { + const category = typeToCategoryMap[type] ?? type; + const types = Object.entries(typeToCategoryMap) + .filter(([, cat]) => cat === category) + .map(([t]) => t); + if (!types.includes(category)) types.push(category); + return types; +} + +// Per-measure contingency rates (fractional, e.g. 0.26 = 26%) +const CONTINGENCIES: Record = { + cavity_wall_insulation: 0.1, + internal_wall_insulation: 0.26, + external_wall_insulation: 0.26, + loft_insulation: 0.1, + solar_pv: 0.15, + air_source_heat_pump: 0.25, + flat_roof_insulation: 0.26, + suspended_floor_insulation: 0.2, + solid_floor_insulation: 0.26, + low_energy_lighting: 0.26, + high_heat_retention_storage_heaters: 0.1, + windows_glazing: 0.15, + boiler_upgrade: 0.26, + time_and_temperature_zone_control: 0.1, + roomstat_programmer_trvs: 0.1, + room_roof_insulation: 0.26, + heater_removal: 0.1, + sealing_open_fireplace: 0.1, + mechanical_ventilation: 0.26, + sloping_ceiling_insulation: 0.26, +}; + +// Local SAP → EPC letter mapping (mirrors sapToEpc in @/app/utils) +function sapToEpcLetter(sapPoints: number): string { + if (sapPoints >= 92) return "A"; + if (sapPoints >= 81) return "B"; + if (sapPoints >= 69) return "C"; + if (sapPoints >= 55) return "D"; + if (sapPoints >= 39) return "E"; + if (sapPoints >= 21) return "F"; + return "G"; +} + +/** + * Sets a recommendation as the default for its category within a plan. + * Clears the default flag from all other recommendations in the same category, + * then sets it on the selected recommendation. + */ +export async function setDefaultRecommendation( + planId: string, + selectedRecId: string, + slug: string, + propertyId: string, + options?: { skipRevalidate?: boolean }, +) { + const planRecs = await db + .select({ recId: planRecommendations.recommendationId }) + .from(planRecommendations) + .where(eq(planRecommendations.planId, BigInt(planId))); + + const recIds = planRecs.map((r) => r.recId); + if (recIds.length === 0) return; + + const [selectedRec] = await db + .select({ type: recommendation.type }) + .from(recommendation) + .where(eq(recommendation.id, BigInt(selectedRecId))); + + if (!selectedRec) return; + + const categoryTypes = getCategoryTypes(selectedRec.type); + + await db.transaction(async (tx) => { + await tx + .update(recommendation) + .set({ default: false }) + .where( + and( + inArray(recommendation.id, recIds), + inArray(recommendation.type, categoryTypes), + ), + ); + await tx + .update(recommendation) + .set({ default: true }) + .where(eq(recommendation.id, BigInt(selectedRecId))); + }); + + if (!options?.skipRevalidate) { + revalidatePath( + `/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`, + ); + } +} + +/** + * Clears the default flag from every recommendation in a category for this plan. + * Used to remove a measure entirely from the plan. + */ +export async function clearCategoryDefault( + planId: string, + categoryType: string, + slug: string, + propertyId: string, + options?: { skipRevalidate?: boolean }, +) { + const planRecs = await db + .select({ recId: planRecommendations.recommendationId }) + .from(planRecommendations) + .where(eq(planRecommendations.planId, BigInt(planId))); + + const recIds = planRecs.map((r) => r.recId); + if (recIds.length === 0) return; + + const categoryTypes = getCategoryTypes(categoryType); + + await db + .update(recommendation) + .set({ default: false }) + .where( + and( + inArray(recommendation.id, recIds), + inArray(recommendation.type, categoryTypes), + ), + ); + + if (!options?.skipRevalidate) { + revalidatePath( + `/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`, + ); + } +} + +/** + * Recalculates and persists plan-level metrics based on the current set of + * default recommendations. Contingency is calculated per-measure using + * measure-specific rates. + */ +export async function updatePlanMetrics( + planId: string, + currentSapPoints: number, + slug: string, + propertyId: string, +) { + const planRecs = await db + .select({ recId: planRecommendations.recommendationId }) + .from(planRecommendations) + .where(eq(planRecommendations.planId, BigInt(planId))); + + const recIds = planRecs.map((r) => r.recId); + + const defaultRecs = + recIds.length > 0 + ? await db + .select() + .from(recommendation) + .where( + and( + inArray(recommendation.id, recIds), + eq(recommendation.default, true), + ), + ) + : []; + + const costOfWorks = defaultRecs.reduce( + (s, r) => s + (r.estimatedCost ?? 0), + 0, + ); + const contingencyCost = defaultRecs.reduce((s, r) => { + const rate = CONTINGENCIES[r.type] ?? 0.125; + return s + (r.estimatedCost ?? 0) * rate; + }, 0); + const co2Savings = defaultRecs.reduce( + (s, r) => s + (r.co2EquivalentSavings ?? 0), + 0, + ); + const energyBillSavings = defaultRecs.reduce( + (s, r) => s + (r.energyCostSavings ?? 0), + 0, + ); + const sapPointsGain = defaultRecs.reduce( + (s, r) => s + (r.sapPoints ?? 0), + 0, + ); + const postSapPoints = currentSapPoints + sapPointsGain; + const postEpcRating = sapToEpcLetter(postSapPoints) as + | "A" + | "B" + | "C" + | "D" + | "E" + | "F" + | "G"; + + await db + .update(plan) + .set({ + costOfWorks, + contingencyCost, + co2Savings, + energyBillSavings, + postSapPoints, + postEpcRating, + }) + .where(eq(plan.id, BigInt(planId))); + + revalidatePath( + `/portfolio/${slug}/building-passport/${propertyId}/plans/${planId}`, + ); +} diff --git a/src/app/components/building-passport/AlternativesDrawer.tsx b/src/app/components/building-passport/AlternativesDrawer.tsx new file mode 100644 index 00000000..bfac9d11 --- /dev/null +++ b/src/app/components/building-passport/AlternativesDrawer.tsx @@ -0,0 +1,110 @@ +"use client"; +import { formatNumber } from "@/app/utils"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/app/shadcn_components/ui/drawer"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import type { AugmentedRec } from "./recommendation-types"; + +interface AlternativesDrawerProps { + isOpen: boolean; + onClose: () => void; + categoryLabel: string; + recs: AugmentedRec[]; + selected: AugmentedRec | null; + displaySelected: AugmentedRec | null; + onSelect: (rec: AugmentedRec) => void; + isPending: boolean; +} + +export default function AlternativesDrawer({ + isOpen, + onClose, + categoryLabel, + recs, + displaySelected, + onSelect, + isPending, +}: AlternativesDrawerProps) { + const alternatives = recs.filter((r) => !r.alreadyInstalled); + + return ( + !open && onClose()}> + + + + {categoryLabel} + +

+ {alternatives.length} option + {alternatives.length !== 1 ? "s" : ""} available — select to update + the plan +

+
+ +
+ {alternatives.map((rec) => { + const isSelected = displaySelected?.id === rec.id; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/app/components/building-passport/StickyImpactBar.tsx b/src/app/components/building-passport/StickyImpactBar.tsx new file mode 100644 index 00000000..942f27c7 --- /dev/null +++ b/src/app/components/building-passport/StickyImpactBar.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; + +function getEpcHex(letter: string | null | undefined): string { + switch (letter?.toUpperCase()) { + case "A": return "#117d58"; + case "B": return "#2da55c"; + case "C": return "#8dbd40"; + case "D": return "#f7cd14"; + case "E": return "#f3a96a"; + case "F": return "#ef8026"; + case "G": return "#e41e3b"; + default: return "#9ca3af"; + } +} + +interface StickyImpactBarProps { + hasPendingChanges: boolean; + pendingCount: number; + savedSap: number; + savedEpc: string; + projectedSap: number; + projectedEpc: string; + projectedCo2: number; + projectedHeatDemand: number; + projectedCost: number; + projectedContingency: number; + onSaveAll: () => void; + onDiscard: () => void; + isPending: boolean; +} + +function formatNumber(n: number): string { + return n.toLocaleString("en-GB", { maximumFractionDigits: 0 }); +} + +export default function StickyImpactBar({ + hasPendingChanges, + pendingCount, + savedSap, + savedEpc, + projectedSap, + projectedEpc, + projectedCo2, + projectedHeatDemand, + projectedCost, + projectedContingency, + onSaveAll, + onDiscard, + isPending, +}: StickyImpactBarProps) { + return ( +
+
+ {/* Left: pending count badge */} + + {pendingCount} unsaved {pendingCount === 1 ? "change" : "changes"} + + + {/* Center: metric chips */} +
+ {/* SAP */} +
+
+

SAP

+
+ + {savedSap.toFixed(1)} + + + + {projectedSap.toFixed(1)} + +
+
+
+ + {/* EPC */} +
+
+

EPC

+
+ + {savedEpc} + + + + {projectedEpc} + +
+
+
+ + {/* CO₂ */} +
+

CO₂ saved

+

+ {projectedCo2.toFixed(1)} t/yr +

+
+ + {/* Heat demand — hidden when zero */} + {projectedHeatDemand > 0 && ( +
+

Heat demand

+

+ {projectedHeatDemand.toFixed(0)} kWh/yr +

+
+ )} + + {/* Cost — hidden when zero */} + {projectedCost > 0 && ( +
+

Est. Cost

+

+ £{formatNumber(projectedCost)} +

+
+ )} + + {/* Contingency — hidden when zero */} + {projectedContingency > 0 && ( +
+

Contingency

+

+ ~£{formatNumber(projectedContingency)} +

+
+ )} +
+ + {/* Right: action buttons */} +
+ {isPending && ( + + )} + + +
+
+
+ ); +} diff --git a/src/app/components/building-passport/recommendation-types.ts b/src/app/components/building-passport/recommendation-types.ts new file mode 100644 index 00000000..b3e6d99e --- /dev/null +++ b/src/app/components/building-passport/recommendation-types.ts @@ -0,0 +1,3 @@ +import { Recommendation } from "@/app/db/schema/recommendations"; + +export type AugmentedRec = Recommendation & { alreadyInstalled: boolean }; diff --git a/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx new file mode 100644 index 00000000..88f721c6 --- /dev/null +++ b/src/app/portfolio/[slug]/building-passport/[propertyId]/plans/[planId]/loading.tsx @@ -0,0 +1,102 @@ +function Skeleton({ className }: { className?: string }) { + return ( +
+ ); +} + +export default function PlanDetailLoading() { + return ( +
+ + {/* Header skeleton */} +
+ + +
+ + {/* Executive Summary Bento skeleton */} +
+ + {/* SAP Improvement card */} +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + {/* 4 stat tiles */} +
+ {[0, 1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+
+ + {/* Financial + Recommendations skeleton */} +
+ + {/* Left: Financial Overview */} +
+ +
+
+ + +
+
+
+ + +
+
+
+
+ + + + +
+
+ + {/* Right: Recommendations */} +
+ + {[0, 1, 2, 3].map((i) => ( +
+
+ +
+ + +
+
+ + +
+
+ +
+ ))} +
+
+
+ ); +}