Merge pull request #120 from Hestia-Homes/eco-project-data

Improving UI for project plan
This commit is contained in:
KhalimCK 2025-11-05 18:29:00 +00:00 committed by GitHub
commit 14697771a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 367 additions and 255 deletions

View file

@ -55,7 +55,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
icon: RocketLaunchIcon,
match: (p: string) =>
p.startsWith(`/portfolio/${portfolioId}/your-projects`),
href: `/portfolio/${portfolioId}/your-projects/proposal`,
href: `/portfolio/${portfolioId}/your-projects/plan`,
},
{
label: "Settings",

View file

@ -12,9 +12,7 @@ export default async function Layout({
return (
<section>
<div className="flex justify-center border-b mb-6 max-w-8xl mx-auto">
<TabLink href={`/portfolio/${slug}/your-projects/proposal`}>
Proposal
</TabLink>
<TabLink href={`/portfolio/${slug}/your-projects/plan`}>Plan</TabLink>
<TabLink href={`/portfolio/${slug}/your-projects/live`}>
Live Reporting
</TabLink>

View file

@ -19,6 +19,7 @@ export default async function LiveReportingPage(props: {
}
// 🏢 Fetch the company
const [company] = await surveyDB
.select()
.from(hubspotCompanyData)

View file

@ -0,0 +1,360 @@
"use client";
import { useState, useMemo } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
CardDescription,
} from "@/app/shadcn_components/ui/card";
import { BarChart, DonutChart } from "@tremor/react";
import { formatNumber } from "@/app/utils";
import {
PoundSterling,
Leaf,
Zap,
Home,
CarIcon,
LineChart,
CoinsIcon,
} from "lucide-react";
import { motion } from "framer-motion";
const mappedTitles: Record<string, string> = {
solar_eco4: "Solar ECO4 project metrics",
solar_hhrsh_eco4: "Solar & HHRSH ECO4 project metrics",
empty_cavity_eco: "Empty Cavity Insulation metrics",
partial_cavity_eco: "Partial Cavity Insulation metrics",
extraction_eco: "Cavity Extraction & Refill project metrics",
default: "Select a work type to view metrics",
};
const displayNames: Record<string, string> = {
solar_eco4: "Solar ECO4",
solar_hhrsh_eco4: "Solar & HHRSH ECO4",
empty_cavity_eco: "Empty Cavity Insulation",
partial_cavity_eco: "Partial Cavity Insulation",
extraction_eco: "Cavity Extraction & Refill",
};
const cardStyles: Record<string, { color: string; icon: React.ElementType }> = {
"Number of Homes": { color: "text-purple-600", icon: Home },
"Estimated Capex Investment": {
color: "text-green-600",
icon: PoundSterling,
},
"Total Funding": { color: "text-brandbrown", icon: CoinsIcon },
"Bill Savings": { color: "text-amber-600", icon: Zap },
"Average post SAP": { color: "text-blue-600", icon: LineChart },
"Carbon Savings": { color: "text-emerald-600", icon: Leaf },
};
export function ProjectProposal({ plans }: { plans: any[] }) {
const grouped = useMemo(() => {
const map: Record<string, any[]> = {};
for (const plan of plans) {
if (!plan.planType) continue;
if (!map[plan.planType]) map[plan.planType] = [];
map[plan.planType].push(plan);
}
return Object.entries(map).map(([type, list]) => {
const totalFunding = list.reduce(
(sum, p) => sum + (p.totalFunding ?? 0),
0
);
const totalClientContribution = list.reduce(
(sum, p) => sum + (p.clientContribution ?? 0),
0
);
const totalCarbon = list.reduce(
(sum, p) => sum + (p.totalCarbonSavings ?? 0),
0
);
const totalBills = list.reduce(
(sum, p) => sum + (p.totalBillSavings ?? 0),
0
);
const avgSapPost =
list.reduce((sum, p) => sum + (p.averageSapPost ?? 0), 0) / list.length;
return {
planType: type,
label: displayNames[type] || type, // 👈 new human-friendly label
count: list.length,
avgClientContribution: totalClientContribution / list.length,
totalClientContribution,
totalFunding,
totalCarbon,
totalBills,
avgSapPost,
};
});
}, [plans]);
const [selectedType, setSelectedType] = useState<string | null>(
grouped[0]?.planType ?? null
);
// Derive the selected data safely
const selectedData = useMemo(() => {
if (!selectedType || !grouped.length) return null;
return grouped.find((d) => d.planType === selectedType) ?? grouped[0];
}, [grouped, selectedType]);
return (
<div className="grid grid-cols-1 grid-cols-4 gap-4">
{/* Chart */}
<Card className="col-span-2 border border-gray-100 bg-gradient-to-br from-white to-gray-50/40 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-medium text-brandblue">
Homes by Work Type
</CardTitle>
{grouped.length > 1 && (
<CardDescription className="text-sm text-gray-500">
Click on the bars to view metrics for each work type.
</CardDescription>
)}
</CardHeader>
<CardContent>
{grouped.length > 1 ? (
<BarChart
data={grouped}
index="label" // 👈 use label instead of planType
categories={["count"]}
colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]}
valueFormatter={(v) => v.toString()}
onValueChange={(v) =>
setSelectedType(
v && typeof v === "object" && "planType" in v
? String((v as any).planType)
: null
)
}
className="h-64"
/>
) : (
<DonutChart
data={grouped}
category="count"
index="planType"
colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]}
valueFormatter={(v) => `${v} home${v === 1 ? "" : "s"}`}
/>
)}
</CardContent>
</Card>
{/* Metrics */}
<Card className="col-span-2 border border-gray-200 bg-white">
<CardHeader>
<CardTitle className="text-lg font-semibold text-brandblue">
{mappedTitles[selectedType || "default"]}
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
{/* --- Top Row: Investment + Funding --- */}
<div className="grid grid-cols-2 gap-4 border-b border-gray-100 pb-6">
<div className="flex flex-col items-start justify-center bg-gradient-to-br from-amber-50/50 to-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 mb-1">
<PoundSterling className="h-4 w-4 text-brandbrown" />
<p className="text-sm text-gray-600 font-medium">
Capex Investment
</p>
</div>
<p className="text-3xl font-bold text-brandbrown leading-tight">
£{formatNumber(selectedData?.totalClientContribution || 0)}
</p>
<p className="text-xs text-gray-500 mt-1">
Avg per home £
{formatNumber(selectedData?.avgClientContribution || 0)}
</p>
</div>
<div className="flex flex-col items-start justify-center bg-gradient-to-br from-blue-50/50 to-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 mb-1">
<CoinsIcon className="h-4 w-4 text-brandblue" />
<p className="text-sm text-gray-600 font-medium">
Funding Unlocked
</p>
</div>
<p className="text-3xl font-bold text-brandblue leading-tight">
£{formatNumber(selectedData?.totalFunding || 0)}
</p>
<p className="text-xs text-gray-500 mt-1">
Total funding available for this work type
</p>
</div>
</div>
{/* --- Bottom Row: Bills, Carbon, Post-SAP --- */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="bg-white rounded-lg shadow-sm p-3 hover:bg-gray-50 transition-colors">
<Zap className="h-4 w-4 mx-auto text-amber-600 mb-1" />
<p className="text-xs text-gray-500 mb-1">Bill Savings</p>
<p className="text-md font-semibold text-brandblue">
£{formatNumber(selectedData?.totalBills || 0)}
</p>
</div>
<div className="bg-white rounded-lg shadow-sm p-3 hover:bg-gray-50 transition-colors">
<Leaf className="h-4 w-4 mx-auto text-emerald-600 mb-1" />
<p className="text-xs text-gray-500 mb-1">Carbon Savings</p>
<p className="text-md font-semibold text-brandblue">
{formatNumber((selectedData?.totalCarbon || 0) * 1000)}
<span className="text-xs text-gray-600 align-bottom">
kgCOe
</span>
</p>
</div>
<div className="bg-white rounded-lg shadow-sm p-3 hover:bg-gray-50 transition-colors">
<LineChart className="h-4 w-4 mx-auto text-blue-600 mb-1" />
<p className="text-xs text-gray-500 mb-1">Avg Post-SAP</p>
<p className="text-md font-semibold text-brandblue">
{selectedData?.avgSapPost
? Number(selectedData.avgSapPost).toFixed(0)
: "—"}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export function DashboardSummary({ plans }: { plans: any[] }) {
const totalFunding = plans.reduce((sum, p) => sum + (p.totalFunding || 0), 0);
const totalInvestment = plans.reduce(
(sum, p) => sum + (p.clientContribution || 0),
0
);
const totalCarbon = plans.reduce(
(sum, p) => sum + (p.totalCarbonSavings || 0),
0
);
const averageSapPost =
plans.reduce((sum, p) => sum + (p.averageSapPost || 0), 0) / plans.length;
const averageBillSavings =
plans.reduce((sum, p) => sum + (p.totalBillSavings || 0), 0) / plans.length;
const totalBills = plans.reduce(
(sum, p) => sum + (p.totalBillSavings || 0),
0
);
const planCount = plans.length;
// Will be using this UK gov report for car stats:
//www.gov.uk/government/statistics/vehicle-licensing-statistics-2022/vehicle-licensing-statistics-2022.
// https: "In the UK, the average CO2 emissions for cars registered for the first time in 2022 was 110.8 grams per kilometre (g/km)";
const avgCarCO2PerYearKg = (108 * 11_000) / 1000; // average 108 g/km × 11,000 km
const carsOffRoad = Math.round((totalCarbon * 1000) / avgCarCO2PerYearKg);
const cards: {
title: string;
value: string | number | React.ReactNode;
subtitle: string | React.ReactNode;
icon: React.ElementType;
}[] = [
{
title: "Number of Homes",
value: planCount,
subtitle: "Properties included across your project plans.",
icon: Home,
},
{
title: "Estimated Capex Investment",
value: `£${formatNumber(totalInvestment)}`,
subtitle:
"This is an estimate of the total investment needed for your project.",
icon: PoundSterling,
},
{
title: "Total Funding",
value: `£${formatNumber(totalFunding)}`,
subtitle: "Domna will help you unlock this much grant funding.",
icon: CoinsIcon,
},
{
title: "Bill Savings",
value: `£${formatNumber(totalBills)}`,
subtitle: `This amounts to a £${formatNumber(averageBillSavings ? Number(averageBillSavings.toFixed(0)) : 0)} saving per home`,
icon: Zap,
},
{
title: "Average post SAP",
value: `${averageSapPost.toFixed(0)}`,
subtitle: "Expected average SAP rating across all homes, after works.",
icon: LineChart,
},
{
title: "Carbon Savings",
value: (
<>
{formatNumber(totalCarbon * 1000)}{" "}
<span className="text-sm text-gray-600 align-bottom">kgCOe</span>
</>
),
subtitle: (
<>
<span>
This is equivalent to removing {carsOffRoad} cars from the road for
one year.
</span>
<CarIcon className="inline-block ml-1 h-4 w-4 text-gray-500 align-middle text-brandbrown" />
</>
),
icon: Leaf,
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{cards.map((c) => {
const style = cardStyles[c.title] || {
color: "text-brandblue",
icon: Home,
};
const Icon = style.icon;
return (
<Card
key={c.title}
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
>
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<div className="p-1.5 rounded-md bg-brandlightblue/40">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="p-1.5 rounded-md bg-brandlightblue/40"
>
<Icon className={`h-4 w-4 ${style.color}`} />
</motion.div>
</div>
<CardTitle className="text-md font-medium text-gray-600 mb-2">
{c.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col pb-2 mb-2">
<div className="flex items-baseline gap-1">
<div className="text-3xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-brandblue to-midblue">
{c.value}
</div>
</div>
</CardContent>
<CardFooter className="pt-0 pb-4">
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardFooter>
</Card>
);
})}
</div>
);
}

View file

@ -23,6 +23,7 @@ export interface PlanWithTotals extends Record<string, unknown> {
surveyCost?: number;
clientContribution?: number;
totalRecommendationSapPoints: number | null;
averageSapPost?: number;
}
export async function getPlansWithTotals(
@ -95,12 +96,15 @@ export async function getPlansWithTotals(
const rawContribution = totalCost + surveyCost - funding - uplift;
const clientContribution = rawContribution > 0 ? rawContribution : 0;
const postSapPoints =
(plan.currentSapPoints ?? 0) + (plan.totalRecommendationSapPoints ?? 0);
return {
...plan,
totalFunding: funding, // overwrite
surveyCost,
clientContribution,
averageSapPost: postSapPoints,
};
});

View file

@ -1,249 +0,0 @@
"use client";
import { useState, useMemo } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/app/shadcn_components/ui/card";
import { BarChart, DonutChart } from "@tremor/react";
import { formatNumber } from "@/app/utils";
import { PoundSterling, Leaf, Zap, Home } from "lucide-react";
import { motion } from "framer-motion";
const mappedTitles: Record<string, string> = {
solar_eco4: "Solar ECO4 project metrics",
solar_hhrsh_eco4: "Solar HHRSH ECO4 project metrics",
empty_cavity_eco: "Empty Cavity Insulation metrics",
partial_cavity_eco: "Partial Cavity Insulation metrics",
extraction_eco: "Extraction & Refill project metrics",
default: "Select a work type to view metrics",
};
export function ProjectProposal({ plans }: { plans: any[] }) {
const [selectedType, setSelectedType] = useState<string | null>(null);
const grouped = useMemo(() => {
const map: Record<string, any[]> = {};
for (const plan of plans) {
if (!plan.planType) continue;
if (!map[plan.planType]) map[plan.planType] = [];
map[plan.planType].push(plan);
}
return Object.entries(map).map(([type, list]) => {
const totalFunding = list.reduce(
(sum, p) => sum + (p.totalFunding ?? 0),
0
);
const totalClientContribution = list.reduce(
(sum, p) => sum + (p.clientContribution ?? 0),
0
);
const totalCarbon = list.reduce(
(sum, p) => sum + (p.totalCarbonSavings ?? 0),
0
);
const totalBills = list.reduce(
(sum, p) => sum + (p.totalBillSavings ?? 0),
0
);
return {
planType: type,
count: list.length,
avgClientContribution: totalClientContribution / list.length,
totalClientContribution,
totalFunding,
totalCarbon,
totalBills,
};
});
}, [plans]);
useMemo(() => {
if (grouped.length === 1 && !selectedType)
setSelectedType(grouped[0].planType);
}, [grouped, selectedType]);
const selectedData =
selectedType && grouped.length
? grouped.find((d) => d.planType === selectedType)
: grouped.length === 1
? grouped[0]
: null;
return (
<div className="grid grid-cols-1 grid-cols-5 gap-4">
{/* Chart */}
<Card className="col-span-3 border border-gray-100 bg-gradient-to-br from-white to-gray-50/40 shadow-sm">
<CardHeader>
<CardTitle className="text-base font-medium text-brandblue">
Homes by Work Type
</CardTitle>
</CardHeader>
<CardContent>
{grouped.length > 1 ? (
<BarChart
data={grouped}
index="planType"
categories={["count"]}
colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]}
valueFormatter={(v) => v.toString()}
onValueChange={(v) =>
setSelectedType(
v && typeof v === "object" && "planType" in v
? String((v as any).planType)
: null
)
}
className="h-64"
/>
) : (
<DonutChart
data={grouped}
category="count"
index="planType"
colors={["#2d348f", "#14163d", "#3943b7", "#5d6be0"]}
valueFormatter={(v) => `${v} home${v === 1 ? "" : "s"}`}
/>
)}
</CardContent>
</Card>
{/* Metrics */}
<Card className="col-span-2 border border-gray-200 bg-white">
<CardHeader>
<CardTitle className="text-lg font-semibold text-brandblue">
{mappedTitles[selectedType || "default"]}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<p className="text-sm text-gray-500 mb-1">Total investment</p>
<p className="text-2xl font-semibold text-brandbrown">
£{formatNumber(selectedData?.totalClientContribution || 0)}
</p>
<p className="text-xs text-gray-500">
Avg per home £
{formatNumber(selectedData?.avgClientContribution || 0)}
</p>
</div>
<div className="grid grid-cols-3 gap-4 text-center border-t pt-4">
<div>
<p className="text-xs text-gray-500 mb-1">Funding</p>
<p className="text-md font-medium text-brandblue">
£{formatNumber(selectedData?.totalFunding || 0)}
</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Carbon</p>
<p className="text-md font-medium text-brandblue">
{((selectedData?.totalCarbon || 0) * 1000).toFixed(0)}{" "}
<span className="text-sm text-gray-600 align-top">kgCOe</span>
</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Bills</p>
<p className="text-md font-medium text-brandblue">
£{formatNumber(selectedData?.totalBills || 0)}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export function DashboardSummary({ plans }: { plans: any[] }) {
const totalFunding = plans.reduce((sum, p) => sum + (p.totalFunding || 0), 0);
const totalCarbon = plans.reduce(
(sum, p) => sum + (p.totalCarbonSavings || 0),
0
);
const totalBills = plans.reduce(
(sum, p) => sum + (p.totalBillSavings || 0),
0
);
const planCount = plans.length;
const cards: {
title: string;
value: string | number;
subtitle: string;
icon: React.ElementType;
}[] = [
{
title: "Total Funding",
value: `£${formatNumber(totalFunding)}`,
subtitle: "Domna will help you unlock this much funding.",
icon: PoundSterling, // ✅ no <PoundSterling />
},
{
title: "Carbon Savings",
value: `${(totalCarbon * 1000).toFixed(2)} kgCO₂e`,
subtitle: "Your projects total estimated CO₂e savings, per year.",
icon: Leaf,
},
{
title: "Bill Savings",
value: `£${formatNumber(totalBills)}`,
subtitle: "Expected total bill reductions across all homes, per year.",
icon: Zap,
},
{
title: "Number of Homes",
value: planCount,
subtitle: "Properties included across your project plans.",
icon: Home,
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((c) => {
const Icon = c.icon;
return (
<Card
key={c.title}
className="border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
>
<CardHeader className="flex flex-row items-center gap-2 pb-1">
<div className="p-1.5 rounded-md bg-brandlightblue/40">
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="p-1.5 rounded-md bg-brandlightblue/40"
>
<Icon
className={`h-4 w-4 ${
c.title.includes("Funding")
? "text-brandbrown"
: c.title.includes("Carbon")
? "text-emerald-600"
: c.title.includes("Bill")
? "text-amber-600"
: "text-brandblue"
}`}
/>
</motion.div>
</div>
<CardTitle className="text-lg font-medium text-gray-600">
{c.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-brandblue to-midblue mb-1 pb-2">
{c.value}
</div>
<p className="text-xs text-gray-500">{c.subtitle}</p>
</CardContent>
</Card>
);
})}
</div>
);
}

View file

@ -15,8 +15,6 @@ export default async function Recommendations(props: {
const planMeta = await getPlanMeta(params.planId);
const funding = await getPlanFunding(params.planId);
console.log("funding", funding);
return (
<div className="leading-loose tracking-wider">
<RecommendationContainer