Adding costing scenario dropdown

This commit is contained in:
Khalim Conn-Kowlessar 2025-12-04 22:05:26 +00:00
parent 1902e89ba8
commit f800db6174
5 changed files with 236 additions and 46 deletions

View file

@ -54,10 +54,13 @@ export function DashboardSummaryCards({
const missingEpcCount = estimatedCounts.estimated;
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
const averageCurrentEpc = sapToEpc(averages.avg_sap || 0);
const hasScenario = Boolean(scenarioOverlay);
// We pull the scenario data from the scenario overlay
function deltaLabel(baseline: number, scenario: number) {
const diff = scenario - baseline;
if (diff === 0) return "No change";
if (diff === 0) return null;
const sign = diff > 0 ? "▲" : "▼";
const color = diff > 0 ? "text-red-600" : "text-emerald-600";
@ -86,7 +89,7 @@ export function DashboardSummaryCards({
scenarioOverlay?.avgSap &&
`${sapToEpc(scenarioOverlay.avgSap.scenario)} (${scenarioOverlay.avgSap.scenario} pts)`,
subtitle: "Current SAP rating across all properties.",
extra: { epc: averageCurrentEpc ?? "Unknown" },
isEpc: true,
},
{
key: "avgCarbon",
@ -130,7 +133,7 @@ export function DashboardSummaryCards({
const Icon = cardStyles[c.key as MetricKey].icon;
const color = cardStyles[c.key as MetricKey].color;
const hasScenario = Boolean(c.scenario);
console.log("Card data:", c);
return (
<Card
@ -141,27 +144,26 @@ export function DashboardSummaryCards({
<motion.div whileHover={{ scale: 1.05 }}>
<Icon className={`h-5 w-5 ${color}`} />
</motion.div>
<CardTitle className="text-md font-medium text-gray-700">
{c.title}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-1">
{/* --- BASELINE + SCENARIO SIDE BY SIDE --- */}
<CardContent className="flex flex-1 flex-col gap-2">
{/* BASELINE + SCENARIO ROW */}
<div
className={`flex items-baseline ${
className={`flex ${
hasScenario ? "justify-between" : "justify-start"
}`}
} items-start`}
>
{/* Baseline */}
{/* BASELINE */}
<div className="flex flex-col">
<span className="text-xs text-gray-500">Baseline</span>
<span
className={
c.title !== "Average EPC Rating"
? "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue"
: `text-3xl font-semibold bg-clip-text ${epcColors[averageCurrentEpc || "Unknown"]}`
c.isEpc
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
@ -171,11 +173,25 @@ export function DashboardSummaryCards({
)}
</div>
{/* Scenario (if selected) */}
{hasScenario && (
{/* SCENARIO */}
{hasScenario && c.scenario && (
<div className="flex flex-col text-right">
<span className="text-xs text-gray-500">Scenario</span>
<span className="text-2xl font-semibold text-brandblue">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
scenarioOverlay?.avgSap?.scenario ??
(averages.avg_sap || 0)
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
@ -184,14 +200,14 @@ export function DashboardSummaryCards({
)}
</div>
{/* Totals */}
{/* TOTAlS */}
{c.totalValue && (
<div className="text-sm text-gray-700 mt-2">{c.totalValue}</div>
<div className="text-sm text-gray-700">{c.totalValue}</div>
)}
{/* Missing EPC Bar */}
{/* Missing EPC bar */}
{c.key === "missingEpc" && (
<div className="w-full mt-2 bg-gray-200 rounded-full h-2">
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="h-2 rounded-full bg-red-500"
style={{ width: `${missingEpcPercent}%` }}

View file

@ -5,6 +5,7 @@ import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
import { DashboardSummaryCards } from "./DashboardSummaryCards";
import { BreakdownChart } from "./BreakdownChart";
import { EpcQualityCards } from "./EpcQualityCards";
import { ScenarioFinancialDrawer } from "./ScenarioFinancialDrawer";
import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider";
import {
@ -37,6 +38,7 @@ export function ReportingClientArea({
);
const [scenarioMetrics, setScenarioMetrics] = useState<any>(null);
const drawerOpen = Boolean(selectedScenarioId);
// 🔥 Hardcoded scenario metrics (replace later with real fetch)
useEffect(() => {
@ -47,13 +49,13 @@ export function ReportingClientArea({
const mocked = {
averages: {
avg_sap: 74,
avg_carbon: 880,
avg_bills: 224000,
avg_sap: 82,
avg_carbon: 1.7,
avg_bills: 1300,
},
totals: {
total_carbon: 880 * 120,
total_bills: 224000 * 120,
total_carbon: 1.7 * 120,
total_bills: 1300 * 120,
},
valuation: {
baseline: 130_000_000,
@ -65,18 +67,43 @@ export function ReportingClientArea({
}, [selectedScenarioId]);
// 👇 Active metrics switch to scenario if present
const activeMetrics = scenarioMetrics
// Baseline always stays baseline
const activeMetrics = baseline;
// Scenario overlay stays separate
const scenarioOverlay = scenarioMetrics
? {
...baseline,
averages: {
...baseline.averages,
avg_sap: scenarioMetrics.averages.avg_sap,
avg_carbon: scenarioMetrics.averages.avg_carbon,
avg_bills: scenarioMetrics.averages.avg_bills,
avgSap: {
baseline: baseline.averages.avg_sap || 0,
scenario: scenarioMetrics.averages.avg_sap,
},
totals: scenarioMetrics.totals,
avgCarbon: {
baseline: baseline.averages.avg_carbon || 0,
scenario: scenarioMetrics.averages.avg_carbon,
},
avgBills: {
baseline: baseline.averages.avg_bills || 0,
scenario: scenarioMetrics.averages.avg_bills,
},
valuation: scenarioMetrics.valuation,
}
: baseline;
: null;
// --------------------
// Scenario Financial Metrics (mocked)
// --------------------
const scenarioFinancial = scenarioMetrics
? {
totalCost: 5_400_000,
contingency: 540_000,
funding: 2_100_000,
costPerSap: 3200,
costPerCo2: 180,
netCost: 5_400_000 - 2_100_000,
netCostPerUnit: (5_400_000 - 2_100_000) / 120,
nUnits: 120,
}
: null;
return (
<>
@ -93,13 +120,15 @@ export function ReportingClientArea({
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioFinancial} />
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-6">
<DashboardSummaryCards
total={activeMetrics.total}
totals={activeMetrics.totals}
averages={activeMetrics.averages}
estimatedCounts={activeMetrics.estimatedCounts}
scenarioOverlay={scenarioMetrics}
scenarioOverlay={scenarioOverlay}
/>
<BreakdownChart

View file

@ -0,0 +1,147 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { formatNumber } from "@/app/utils";
// Premium Icons
import {
Banknote,
ShieldAlert,
PiggyBank,
Scale,
Gauge,
Factory,
Home,
Users,
} from "lucide-react";
export function ScenarioFinancialDrawer({
open,
metrics,
}: {
open: boolean;
metrics: any | null;
}) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
<motion.div
key="drawer"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6">
<h3 className="text-lg font-semibold text-brandblue mb-4">
Scenario Financial Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<Metric
label="Total Cost"
value={`£${formatNumber(metrics.totalCost)}`}
icon={Banknote}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
icon={ShieldAlert}
color="text-amber-600"
bg="bg-amber-200/40"
/>
<Metric
label="Funding"
value={`£${formatNumber(metrics.funding)}`}
icon={PiggyBank}
color="text-emerald-600"
bg="bg-emerald-200/40"
/>
<Metric
label="Net Cost"
value={`£${formatNumber(metrics.netCost)}`}
icon={Scale}
color="text-red-600"
bg="bg-red-200/40"
/>
<Metric
label="Cost per SAP point"
value={`£${formatNumber(metrics.costPerSap)}`}
icon={Gauge}
color="text-purple-600"
bg="bg-purple-200/40"
/>
<Metric
label="Cost per tonne CO₂"
value={`£${formatNumber(metrics.costPerCo2)}`}
icon={Factory}
color="text-slate-700"
bg="bg-slate-200/40"
/>
<Metric
label="Net Cost per Unit"
value={`£${formatNumber(metrics.netCostPerUnit)}`}
icon={Home}
color="text-sky-600"
bg="bg-sky-200/40"
/>
<Metric
label="Units Upgraded"
value={metrics.nUnits}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
function Metric({
label,
value,
icon: Icon,
color,
bg,
}: {
label: string;
value: string | number;
icon: any;
color: string;
bg: string;
}) {
return (
<div
className="group flex flex-col rounded-lg border border-gray-200
bg-gradient-to-br from-white to-gray-50
p-3 shadow-sm hover:shadow-md
hover:border-brandblue/30 transition-all"
>
<div className="flex items-center gap-2 mb-1">
{/* coloured icon background */}
<div
className={`p-1.5 rounded-md ${bg} group-hover:opacity-80 transition`}
>
<Icon className={`h-4 w-4 ${color}`} />
</div>
<span className="text-[10px] uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
</div>
<span
className="text-lg md:text-xl font-semibold text-gray-900
group-hover:text-brandblue transition truncate"
>
{value}
</span>
</div>
);
}

View file

@ -26,13 +26,6 @@ export default async function ReportingPage(props: {
]);
const scenarios = await getScenarios(Number(portfolioId));
const mockScenarioOverlay = {
avgSap: { baseline: 62, scenario: 74 },
avgCarbon: { baseline: 1120, scenario: 880 },
avgBills: { baseline: 240000, scenario: 224000 },
valuation: { baseline: 130000000, scenario: 136000000 },
};
return (
<div className="max-w-8xl mx-auto px-6 pb-10 space-y-4 pt-4">
<div className="mb-6">

View file

@ -15,13 +15,18 @@ export function ScenarioSelectorWrapper({
setSelectedScenarioId: (id: number | null) => void;
}) {
// The ID we will eventually pass into React Query
const activeContextId = useMemo(
() => selectedScenarioId ?? portfolioId,
[selectedScenarioId, portfolioId]
);
// const activeContextId = useMemo(
// () => selectedScenarioId ?? portfolioId,
// [selectedScenarioId, portfolioId]
// );
const [selectedScenarioName, setSelectedScenarioName] = useState<
string | null
>(null);
function handleSelect(id: number | null) {
setSelectedScenarioId(id);
const scenario = scenarios.find((s) => s.id === id);
setSelectedScenarioName(scenario ? scenario.name : null);
}
return (
@ -34,7 +39,7 @@ export function ScenarioSelectorWrapper({
{selectedScenarioId !== null ? (
<div className="text-xs text-gray-500">
Scenario selected: {selectedScenarioId}
Scenario selected: {selectedScenarioName}
</div>
) : (
<div className="text-xs text-gray-400">Using portfolio baseline</div>