added missing files

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-11 08:05:41 +00:00
parent c9adaab234
commit 6339614720
5 changed files with 617 additions and 0 deletions

View file

@ -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<string, string> = {
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<string, number> = {
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}`,
);
}

View file

@ -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 (
<Drawer open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DrawerContent className="max-h-[80vh]">
<DrawerHeader className="px-6 pt-4 pb-3 border-b border-gray-100">
<DrawerTitle className="font-manrope text-xl font-bold text-brandblue">
{categoryLabel}
</DrawerTitle>
<p className="text-sm text-gray-400 mt-0.5">
{alternatives.length} option
{alternatives.length !== 1 ? "s" : ""} available select to update
the plan
</p>
</DrawerHeader>
<div className="overflow-y-auto px-6 py-4 space-y-2 pb-10">
{alternatives.map((rec) => {
const isSelected = displaySelected?.id === rec.id;
return (
<button
key={String(rec.id)}
disabled={isPending}
onClick={() => {
onSelect(rec);
onClose();
}}
className={`w-full text-left p-5 rounded-xl border transition-colors ${
isSelected
? "border-brandblue bg-brandblue/5"
: "border-gray-100 bg-white hover:bg-gray-50"
} disabled:opacity-50`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
isSelected
? "border-brandblue bg-brandblue"
: "border-gray-300"
}`}
>
{isSelected && (
<CheckIcon className="w-3 h-3 text-white" />
)}
</div>
<div className="min-w-0">
<p className="font-semibold text-brandblue text-sm leading-snug">
{rec.description}
</p>
<div className="flex items-center gap-3 mt-1.5">
{rec.sapPoints !== null && rec.sapPoints > 0 && (
<span className="text-xs text-gray-400 font-medium">
SAP +{rec.sapPoints.toFixed(1)}
</span>
)}
{rec.energyCostSavings !== null &&
rec.energyCostSavings > 0 && (
<span className="text-xs text-gray-400 font-medium">
£{formatNumber(rec.energyCostSavings)}/yr saved
</span>
)}
</div>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="font-manrope font-bold text-brandblue">
£{formatNumber(rec.estimatedCost ?? 0)}
</p>
</div>
</div>
</button>
);
})}
</div>
</DrawerContent>
</Drawer>
);
}

View file

@ -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 (
<div
className={`fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/95 backdrop-blur-md shadow-[0_-8px_30px_rgba(0,0,0,0.08)] transition-transform duration-300 ${
hasPendingChanges ? "translate-y-0" : "translate-y-full"
}`}
>
<div className="max-w-[1400px] mx-auto px-6 py-4 flex items-center gap-4 flex-wrap">
{/* Left: pending count badge */}
<span className="text-xs font-bold px-3 py-1.5 rounded-full bg-amber-100 text-amber-700 shrink-0">
{pendingCount} unsaved {pendingCount === 1 ? "change" : "changes"}
</span>
{/* Center: metric chips */}
<div className="flex items-center gap-3 flex-1 flex-wrap">
{/* SAP */}
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100 flex items-center gap-2">
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">SAP</p>
<div className="flex items-center gap-1.5">
<span className="text-sm font-black text-gray-400 font-manrope">
{savedSap.toFixed(1)}
</span>
<ArrowRightIcon className="w-3 h-3 text-gray-300" />
<span className="text-sm font-black text-brandblue font-manrope">
{projectedSap.toFixed(1)}
</span>
</div>
</div>
</div>
{/* EPC */}
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100 flex items-center gap-2">
<div>
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">EPC</p>
<div className="flex items-center gap-1.5">
<span
className="text-sm font-black font-manrope"
style={{ color: getEpcHex(savedEpc) }}
>
{savedEpc}
</span>
<ArrowRightIcon className="w-3 h-3 text-gray-300" />
<span
className="text-sm font-black font-manrope"
style={{ color: getEpcHex(projectedEpc) }}
>
{projectedEpc}
</span>
</div>
</div>
</div>
{/* CO₂ */}
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">CO saved</p>
<p className="text-sm font-black text-brandblue font-manrope">
{projectedCo2.toFixed(1)} t/yr
</p>
</div>
{/* Heat demand — hidden when zero */}
{projectedHeatDemand > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Heat demand</p>
<p className="text-sm font-black text-brandblue font-manrope">
{projectedHeatDemand.toFixed(0)} kWh/yr
</p>
</div>
)}
{/* Cost — hidden when zero */}
{projectedCost > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Est. Cost</p>
<p className="text-sm font-black text-brandblue font-manrope">
£{formatNumber(projectedCost)}
</p>
</div>
)}
{/* Contingency — hidden when zero */}
{projectedContingency > 0 && (
<div className="px-3 py-2 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-[9px] font-bold uppercase tracking-widest text-gray-400 mb-0.5">Contingency</p>
<p className="text-sm font-black text-brandbrown font-manrope">
~£{formatNumber(projectedContingency)}
</p>
</div>
)}
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-2 shrink-0">
{isPending && (
<ArrowPathIcon className="w-4 h-4 animate-spin text-gray-400" />
)}
<button
disabled={isPending}
onClick={onDiscard}
className="px-4 py-2 rounded-lg border border-gray-200 text-gray-600 font-bold text-sm hover:bg-gray-50 transition-colors disabled:opacity-40"
>
Discard
</button>
<button
disabled={isPending}
onClick={onSaveAll}
className="px-4 py-2 rounded-lg bg-brandblue text-white font-bold text-sm hover:bg-blue-700 transition-colors disabled:opacity-40"
>
Save all changes
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,3 @@
import { Recommendation } from "@/app/db/schema/recommendations";
export type AugmentedRec = Recommendation & { alreadyInstalled: boolean };

View file

@ -0,0 +1,102 @@
function Skeleton({ className }: { className?: string }) {
return (
<div className={`animate-pulse bg-gray-100 rounded-lg ${className ?? ""}`} />
);
}
export default function PlanDetailLoading() {
return (
<div className="max-w-[1400px] mx-auto py-10 space-y-12">
{/* Header skeleton */}
<div className="space-y-3">
<Skeleton className="h-12 w-2/3 rounded-xl" />
<Skeleton className="h-5 w-1/2" />
</div>
{/* Executive Summary Bento skeleton */}
<section className="grid grid-cols-1 md:grid-cols-4 lg:grid-cols-6 gap-6">
{/* SAP Improvement card */}
<div className="md:col-span-2 lg:col-span-2 bg-white p-7 rounded-2xl border border-gray-100 shadow-sm flex flex-col gap-5">
<Skeleton className="h-3 w-28" />
<div className="flex items-end gap-3">
<div className="flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-12 w-10 rounded-lg" />
</div>
<Skeleton className="h-5 w-5 mb-2 rounded" />
<div className="flex flex-col items-center gap-1.5">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-16 w-14 rounded-lg" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-full rounded-full" />
<Skeleton className="h-3 w-full" />
</div>
<Skeleton className="h-4 w-40" />
</div>
{/* 4 stat tiles */}
<div className="md:col-span-2 lg:col-span-4 grid grid-cols-2 gap-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-blue-50 p-5 rounded-xl flex flex-col justify-between gap-3 border border-blue-100">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-8 w-24 rounded-lg" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
</section>
{/* Financial + Recommendations skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
{/* Left: Financial Overview */}
<div className="lg:col-span-4 space-y-6">
<Skeleton className="h-3 w-40" />
<div className="bg-brandblue/10 p-8 rounded-2xl space-y-6">
<div className="space-y-2">
<Skeleton className="h-3 w-28 bg-gray-200" />
<Skeleton className="h-12 w-40 rounded-lg bg-gray-200" />
</div>
<div className="pt-6 border-t border-gray-200 flex justify-between">
<div className="space-y-2">
<Skeleton className="h-3 w-20 bg-gray-200" />
<Skeleton className="h-6 w-24 rounded-lg bg-gray-200" />
</div>
</div>
</div>
<div className="bg-brandlightblue rounded-xl border border-blue-100 p-5 space-y-2">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
<Skeleton className="h-3 w-3/5" />
</div>
</div>
{/* Right: Recommendations */}
<div className="lg:col-span-8 space-y-8">
<Skeleton className="h-3 w-44" />
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white rounded-xl border border-gray-100 shadow-sm p-8 space-y-4">
<div className="flex items-center gap-5">
<Skeleton className="h-14 w-14 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48 rounded-lg" />
<Skeleton className="h-3 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-12 w-20 rounded-lg" />
<Skeleton className="h-12 w-20 rounded-lg" />
</div>
</div>
<Skeleton className="h-12 w-full rounded-xl" />
</div>
))}
</div>
</div>
</div>
);
}