mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
Adding costing scenario dropdown
This commit is contained in:
parent
1902e89ba8
commit
f800db6174
5 changed files with 236 additions and 46 deletions
|
|
@ -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}%` }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue