mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
updating ui for new reporting page and started adding scenarios
This commit is contained in:
parent
52d5a562c9
commit
1902e89ba8
12 changed files with 1115 additions and 43 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { property } from "./property";
|
||||
import { property, epcEnum } from "./property";
|
||||
import { goalEnum, portfolio } from "./portfolio";
|
||||
import {
|
||||
bigserial,
|
||||
|
|
@ -82,22 +82,65 @@ export const planTypeEnum = pgEnum("plan_type", PlanType);
|
|||
|
||||
export const plan = pgTable("plan", {
|
||||
id: bigserial("id", { mode: "bigint" }).primaryKey(),
|
||||
|
||||
name: text("name"),
|
||||
|
||||
portfolioId: bigint("portfolio_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => portfolio.id),
|
||||
|
||||
propertyId: bigint("property_id", { mode: "bigint" })
|
||||
.notNull()
|
||||
.references(() => property.id),
|
||||
|
||||
scenarioId: bigint("scenario_id", { mode: "bigint" }).references(
|
||||
() => scenario.id
|
||||
),
|
||||
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
isDefault: boolean("is_default").notNull(),
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Valuation metrics (existing)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
valuationIncreaseLowerBound: real("valuation_increase_lower_bound"),
|
||||
valuationIncreaseUpperBound: real("valuation_increase_upper_bound"),
|
||||
valuationIncreaseAverage: real("valuation_increase_average"),
|
||||
planType: planTypeEnum("plan_type"), // This may be null for custom plans, outside of our common plan types
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NEW — SAP / EPC
|
||||
// ─────────────────────────────────────────────────────────
|
||||
postSapPoints: real("post_sap_points"),
|
||||
postEpcRating: epcEnum("post_epc_rating"),
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NEW — Carbon emissions (tonnes CO₂e/yr)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
postCo2Emissions: real("post_co2_emissions"),
|
||||
co2Savings: real("co2_savings"), // baseline - post
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NEW — Energy bills
|
||||
// ─────────────────────────────────────────────────────────
|
||||
postEnergyBill: real("post_energy_bill"),
|
||||
energyBillSavings: real("energy_bill_savings"),
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NEW — Energy demand (kWh/year)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
postEnergyConsumption: real("post_energy_consumption"),
|
||||
energyConsumptionSavings: real("energy_consumption_savings"),
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// NEW — Valuation
|
||||
// ─────────────────────────────────────────────────────────
|
||||
valuationPostRetrofit: real("valuation_post_retrofit"),
|
||||
valuationIncrease: real("valuation_increase"),
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Plan type stays as-is
|
||||
// ─────────────────────────────────────────────────────────
|
||||
planType: planTypeEnum("plan_type"),
|
||||
});
|
||||
|
||||
export const planRecommendations = pgTable("plan_recommendations", {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { BarChart } from "@tremor/react";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import type { EpcBandCount, AgeBandCount, PropertyTypeCount } from "./types";
|
||||
|
||||
export function BreakdownChart({
|
||||
epcBands,
|
||||
ageBands,
|
||||
propertyTypes,
|
||||
}: {
|
||||
epcBands: EpcBandCount[];
|
||||
ageBands: AgeBandCount[];
|
||||
propertyTypes: PropertyTypeCount[];
|
||||
}) {
|
||||
const [selected, setSelected] = useState("epc");
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
switch (selected) {
|
||||
case "epc":
|
||||
return epcBands.map((d) => ({
|
||||
label: d.epc ?? "Unknown",
|
||||
actual: d.actual ?? 0,
|
||||
estimated: d.estimated ?? 0,
|
||||
}));
|
||||
case "age":
|
||||
return ageBands.map((d) => ({
|
||||
label: d.age_band,
|
||||
count: d.count,
|
||||
}));
|
||||
case "type":
|
||||
return propertyTypes.map((d) => ({
|
||||
label: d.type ?? "Unknown",
|
||||
count: d.count,
|
||||
}));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [selected, epcBands, ageBands, propertyTypes]);
|
||||
|
||||
const categories = selected === "epc" ? ["actual", "estimated"] : ["count"];
|
||||
|
||||
const colors =
|
||||
selected === "epc"
|
||||
? ["#14163d", "#3943b7"] // brand colours: actual + estimated
|
||||
: ["#2d348f"]; // your existing colour
|
||||
|
||||
return (
|
||||
<Card className="border border-gray-100 bg-white">
|
||||
<CardHeader className="flex flex-col space-y-1 items-start">
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<CardTitle className="text-md text-brandblue">
|
||||
Property Breakdown
|
||||
</CardTitle>
|
||||
|
||||
<Select value={selected} onValueChange={setSelected}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Select breakdown" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="epc">EPC Band</SelectItem>
|
||||
<SelectItem value="age">Age Band</SelectItem>
|
||||
<SelectItem value="type">Property Type</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selected === "epc" && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Where an EPC is missing, the EPC band shown here is a modelled
|
||||
estimate.
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="label"
|
||||
categories={categories}
|
||||
colors={colors}
|
||||
valueFormatter={(v) => v.toString()}
|
||||
stack={selected === "epc"} // enable stacked bars only for EPC mode
|
||||
className="h-64"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react";
|
||||
import { formatNumber } from "@/app/utils";
|
||||
import type {
|
||||
AverageMetrics,
|
||||
EstimatedCounts,
|
||||
TotalMetrics,
|
||||
ScenarioOverlayMetrics,
|
||||
} from "./types";
|
||||
import type { MetricKey } from "./types";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
|
||||
const cardStyles = {
|
||||
totalHomes: { icon: Home, color: "text-purple-600" },
|
||||
avgSap: { icon: LineChart, color: "text-blue-600" },
|
||||
avgCarbon: { icon: Leaf, color: "text-emerald-600" },
|
||||
avgBills: { icon: Zap, color: "text-amber-600" },
|
||||
missingEpc: { icon: FileQuestionIcon, color: "text-red-600" },
|
||||
} as Record<MetricKey, { icon: React.ComponentType<any>; color: string }>;
|
||||
|
||||
const epcColors: Record<string, string> = {
|
||||
A: "text-epc_a",
|
||||
B: "text-epc_b",
|
||||
C: "text-epc_c",
|
||||
D: "text-epc_d",
|
||||
E: "text-epc_e",
|
||||
F: "text-epc_f",
|
||||
G: "text-epc_g",
|
||||
Unknown: "text-gray-400",
|
||||
};
|
||||
|
||||
export function DashboardSummaryCards({
|
||||
total,
|
||||
totals,
|
||||
averages,
|
||||
estimatedCounts,
|
||||
scenarioOverlay,
|
||||
}: {
|
||||
total: number;
|
||||
totals: TotalMetrics;
|
||||
averages: AverageMetrics;
|
||||
estimatedCounts: EstimatedCounts;
|
||||
scenarioOverlay?: ScenarioOverlayMetrics | null;
|
||||
}) {
|
||||
const missingEpcCount = estimatedCounts.estimated;
|
||||
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
|
||||
const averageCurrentEpc = sapToEpc(averages.avg_sap || 0);
|
||||
|
||||
function deltaLabel(baseline: number, scenario: number) {
|
||||
const diff = scenario - baseline;
|
||||
if (diff === 0) return "No change";
|
||||
|
||||
const sign = diff > 0 ? "▲" : "▼";
|
||||
const color = diff > 0 ? "text-red-600" : "text-emerald-600";
|
||||
|
||||
return (
|
||||
<span className={`text-sm font-medium ${color}`}>
|
||||
{sign} {formatNumber(Math.abs(diff))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: "totalHomes",
|
||||
title: "Number of Homes",
|
||||
baseline: total,
|
||||
scenario: null,
|
||||
units: "",
|
||||
subtitle: "Total properties in this portfolio.",
|
||||
},
|
||||
{
|
||||
key: "avgSap",
|
||||
title: "Average EPC Rating",
|
||||
baseline: `${averageCurrentEpc} (${Math.round(averages.avg_sap ?? 0)} pts)`,
|
||||
scenario:
|
||||
scenarioOverlay?.avgSap &&
|
||||
`${sapToEpc(scenarioOverlay.avgSap.scenario)} (${scenarioOverlay.avgSap.scenario} pts)`,
|
||||
subtitle: "Current SAP rating across all properties.",
|
||||
extra: { epc: averageCurrentEpc ?? "Unknown" },
|
||||
},
|
||||
{
|
||||
key: "avgCarbon",
|
||||
title: "Carbon Emissions",
|
||||
baseline: formatNumber(averages.avg_carbon ?? 0),
|
||||
scenario:
|
||||
scenarioOverlay?.avgCarbon &&
|
||||
formatNumber(scenarioOverlay.avgCarbon.scenario),
|
||||
units: "tCO₂e per home",
|
||||
subtitle: "Average annual CO₂ output per home.",
|
||||
totalValue: `Total: ${formatNumber(totals.total_carbon ?? 0)} tCO₂e`,
|
||||
delta:
|
||||
scenarioOverlay?.avgCarbon &&
|
||||
deltaLabel(
|
||||
scenarioOverlay.avgCarbon.baseline,
|
||||
scenarioOverlay.avgCarbon.scenario
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "avgBills",
|
||||
title: "Energy Bills",
|
||||
baseline: formatNumber(averages.avg_bills ?? 0),
|
||||
scenario:
|
||||
scenarioOverlay?.avgBills &&
|
||||
formatNumber(scenarioOverlay.avgBills.scenario),
|
||||
units: "per home",
|
||||
subtitle: "Estimated annual energy bills.",
|
||||
totalValue: `Total: £${formatNumber(totals.total_bills ?? 0)}`,
|
||||
delta:
|
||||
scenarioOverlay?.avgBills &&
|
||||
deltaLabel(
|
||||
scenarioOverlay.avgBills.baseline,
|
||||
scenarioOverlay.avgBills.scenario
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = cardStyles[c.key as MetricKey].icon;
|
||||
const color = cardStyles[c.key as MetricKey].color;
|
||||
|
||||
const hasScenario = Boolean(c.scenario);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={c.key}
|
||||
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-brandlightblue/10 hover:shadow-lg transition-all duration-300"
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-1">
|
||||
<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 --- */}
|
||||
<div
|
||||
className={`flex items-baseline ${
|
||||
hasScenario ? "justify-between" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
{/* 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.key === "avgBills" ? `£${c.baseline}` : c.baseline}
|
||||
</span>
|
||||
{c.units && (
|
||||
<span className="text-sm text-gray-500">{c.units}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scenario (if selected) */}
|
||||
{hasScenario && (
|
||||
<div className="flex flex-col text-right">
|
||||
<span className="text-xs text-gray-500">Scenario</span>
|
||||
<span className="text-2xl font-semibold text-brandblue">
|
||||
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
|
||||
</span>
|
||||
|
||||
{c.delta && <div>{c.delta}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
{c.totalValue && (
|
||||
<div className="text-sm text-gray-700 mt-2">{c.totalValue}</div>
|
||||
)}
|
||||
|
||||
{/* Missing EPC Bar */}
|
||||
{c.key === "missingEpc" && (
|
||||
<div className="w-full mt-2 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-red-500"
|
||||
style={{ width: `${missingEpcPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import { FileQuestion, BarChart3 } from "lucide-react";
|
||||
import type { EstimatedCounts } from "./types";
|
||||
|
||||
export function EpcQualityCards({
|
||||
estimatedCounts,
|
||||
total,
|
||||
}: {
|
||||
estimatedCounts: EstimatedCounts;
|
||||
total: number;
|
||||
}) {
|
||||
const missing = estimatedCounts.estimated;
|
||||
const pctMissing = total > 0 ? (missing / total) * 100 : 0;
|
||||
const pctValid = 100 - pctMissing;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: "missing",
|
||||
title: "Homes Without an EPC",
|
||||
icon: FileQuestion,
|
||||
color: "text-red-600",
|
||||
value: missing,
|
||||
subtitle: `${pctMissing.toFixed(1)}% missing EPCs (predicted only)`,
|
||||
barColor: "bg-red-500",
|
||||
barWidth: pctMissing,
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
title: "EPC Data Coverage",
|
||||
icon: BarChart3,
|
||||
color: "text-brandblue",
|
||||
value: `${pctValid.toFixed(1)}%`,
|
||||
subtitle: "Percentage of homes with a valid EPC.",
|
||||
barColor: "bg-brandblue",
|
||||
barWidth: pctValid,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<Card
|
||||
key={c.key}
|
||||
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-red-50/20 hover:shadow-md hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-1">
|
||||
<div className="p-1.5 rounded-md bg-gray-100">
|
||||
<motion.div whileHover={{ scale: 1.1 }} className="p-1">
|
||||
<Icon className={`h-4 w-4 ${c.color}`} />
|
||||
</motion.div>
|
||||
</div>
|
||||
<CardTitle className="text-md font-medium text-gray-600">
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col pb-2">
|
||||
<div className="text-2xl font-semibold text-brandblue">
|
||||
{c.value}
|
||||
</div>
|
||||
|
||||
{/* Correct mini bar per card */}
|
||||
<div className="w-full mt-3 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${c.barColor}`}
|
||||
style={{ width: `${c.barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AlertTriangle,
|
||||
FileQuestion,
|
||||
ClipboardList,
|
||||
ClipboardX,
|
||||
PoundSterling,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
|
||||
type PlaceholderCard = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const CONDITION_PLACEHOLDERS = [
|
||||
{
|
||||
key: "awwabs",
|
||||
title: "Awaabs Law Warnings",
|
||||
subtitle: "Severe hazards related to damp & mould.",
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600",
|
||||
},
|
||||
{
|
||||
key: "cat12",
|
||||
title: "Category 1 & 2 Hazards",
|
||||
subtitle: "Safety risks identified under HHSRS.",
|
||||
icon: AlertTriangle,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
{
|
||||
key: "noStockSurvey",
|
||||
title: "Missing Stock Condition Survey",
|
||||
subtitle: "Properties without a structural/condition survey.",
|
||||
icon: ClipboardX,
|
||||
color: "text-brandblue",
|
||||
},
|
||||
{
|
||||
key: "noDecentHomes",
|
||||
title: "Missing Decent Homes Survey",
|
||||
subtitle: "Properties lacking a Decent Homes standard review.",
|
||||
icon: ClipboardList,
|
||||
color: "text-brandblue",
|
||||
},
|
||||
];
|
||||
|
||||
export const FINANCIAL_PLACEHOLDERS = [
|
||||
{
|
||||
key: "rent",
|
||||
title: "Rent",
|
||||
subtitle: "Historic or current rent information.",
|
||||
icon: PoundSterling,
|
||||
color: "text-brandbrown",
|
||||
},
|
||||
{
|
||||
key: "valuation",
|
||||
title: "Valuation",
|
||||
subtitle: "Property valuation data.",
|
||||
icon: Home,
|
||||
color: "text-midblue",
|
||||
},
|
||||
];
|
||||
|
||||
export function PlaceholderMetricCards({
|
||||
items,
|
||||
}: {
|
||||
items: PlaceholderCard[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{items.map((c) => {
|
||||
const Icon = c.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={c.key}
|
||||
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-gray-50 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-gray-100">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-1.5 rounded-md bg-gray-50"
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${c.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="text-lg font-semibold text-gray-400 italic">
|
||||
Data not provided
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
|
||||
import { DashboardSummaryCards } from "./DashboardSummaryCards";
|
||||
import { BreakdownChart } from "./BreakdownChart";
|
||||
import { EpcQualityCards } from "./EpcQualityCards";
|
||||
|
||||
import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider";
|
||||
import {
|
||||
PlaceholderMetricCards,
|
||||
CONDITION_PLACEHOLDERS,
|
||||
FINANCIAL_PLACEHOLDERS,
|
||||
} from "@/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards";
|
||||
|
||||
import type {
|
||||
BaselineMetrics,
|
||||
PropertyTypeCount,
|
||||
ScenarioSummary,
|
||||
} from "./types";
|
||||
|
||||
interface ReportingClientAreaProps {
|
||||
baseline: BaselineMetrics;
|
||||
propertyTypes: PropertyTypeCount[];
|
||||
scenarios: ScenarioSummary[];
|
||||
portfolioId: number;
|
||||
}
|
||||
|
||||
export function ReportingClientArea({
|
||||
baseline,
|
||||
propertyTypes,
|
||||
scenarios,
|
||||
portfolioId,
|
||||
}: ReportingClientAreaProps) {
|
||||
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [scenarioMetrics, setScenarioMetrics] = useState<any>(null);
|
||||
|
||||
// 🔥 Hardcoded scenario metrics (replace later with real fetch)
|
||||
useEffect(() => {
|
||||
if (!selectedScenarioId) {
|
||||
setScenarioMetrics(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const mocked = {
|
||||
averages: {
|
||||
avg_sap: 74,
|
||||
avg_carbon: 880,
|
||||
avg_bills: 224000,
|
||||
},
|
||||
totals: {
|
||||
total_carbon: 880 * 120,
|
||||
total_bills: 224000 * 120,
|
||||
},
|
||||
valuation: {
|
||||
baseline: 130_000_000,
|
||||
scenario: 136_000_000,
|
||||
},
|
||||
};
|
||||
console.log("Setting mocked scenario metrics");
|
||||
setScenarioMetrics(mocked);
|
||||
}, [selectedScenarioId]);
|
||||
|
||||
// 👇 Active metrics switch to scenario if present
|
||||
const activeMetrics = scenarioMetrics
|
||||
? {
|
||||
...baseline,
|
||||
averages: {
|
||||
...baseline.averages,
|
||||
avg_sap: scenarioMetrics.averages.avg_sap,
|
||||
avg_carbon: scenarioMetrics.averages.avg_carbon,
|
||||
avg_bills: scenarioMetrics.averages.avg_bills,
|
||||
},
|
||||
totals: scenarioMetrics.totals,
|
||||
}
|
||||
: baseline;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScenarioSelectorWrapper
|
||||
scenarios={scenarios}
|
||||
portfolioId={portfolioId}
|
||||
selectedScenarioId={selectedScenarioId}
|
||||
setSelectedScenarioId={setSelectedScenarioId}
|
||||
/>
|
||||
|
||||
{/* --- RETROFIT SECTION --- */}
|
||||
<SectionDivider
|
||||
title="Retrofit Summary"
|
||||
subtitle="High-level insights on performance, energy, and EPC quality."
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<BreakdownChart
|
||||
epcBands={activeMetrics.epcBands}
|
||||
ageBands={activeMetrics.ageBands}
|
||||
propertyTypes={propertyTypes}
|
||||
/>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<EpcQualityCards
|
||||
estimatedCounts={activeMetrics.estimatedCounts}
|
||||
total={activeMetrics.total}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- CONDITION SECTION --- */}
|
||||
<SectionDivider
|
||||
title="Condition"
|
||||
subtitle="Awaabs Law, decent homes and compliance"
|
||||
/>
|
||||
<PlaceholderMetricCards items={CONDITION_PLACEHOLDERS} />
|
||||
|
||||
{/* --- FINANCIAL SECTION --- */}
|
||||
<SectionDivider
|
||||
title="Financial Overview"
|
||||
subtitle="Total bills, cost exposure, and potential funding pathways."
|
||||
/>
|
||||
<PlaceholderMetricCards items={FINANCIAL_PLACEHOLDERS} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function SectionDivider({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-semibold text-brandblue tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{/* Animated gradient line */}
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: "100%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="h-1 mt-2 rounded-full bg-gradient-to-r from-brandblue via-midblue to-brandlightblue"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { scenario } from "@/app/db/schema/recommendations";
|
||||
|
||||
import type {
|
||||
AverageMetrics,
|
||||
AgeBandCount,
|
||||
EpcBandCount,
|
||||
EstimatedCounts,
|
||||
PropertyTypeCount,
|
||||
BaselineMetrics,
|
||||
TotalMetrics,
|
||||
} from "./types";
|
||||
|
||||
export async function getPortfolioCounts(portfolioId: number): Promise<number> {
|
||||
const result = await db.execute<{ total: number }>(sql`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0].total;
|
||||
}
|
||||
|
||||
export async function getAverages(
|
||||
portfolioId: number
|
||||
): Promise<AverageMetrics> {
|
||||
const result = await db.execute<AverageMetrics>(sql`
|
||||
SELECT
|
||||
AVG(p.current_sap_points)::float AS avg_sap,
|
||||
AVG(e.co2_emissions)::float AS avg_carbon,
|
||||
AVG(
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge
|
||||
)::float AS avg_bills,
|
||||
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
|
||||
const result = await db.execute<TotalMetrics>(sql`
|
||||
SELECT
|
||||
SUM(e.co2_emissions)::float AS total_carbon,
|
||||
SUM(
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge
|
||||
)::float AS total_bills
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getCountByAgeBand(
|
||||
portfolioId: number
|
||||
): Promise<AgeBandCount[]> {
|
||||
const result = await db.execute<AgeBandCount>(sql`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN year_built ~ '^[0-9]+$' THEN
|
||||
CASE
|
||||
WHEN CAST(year_built AS int) < 1900 THEN '<1900'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1900 AND 1929 THEN '1900–1929'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '1930–1949'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '1950–1975'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '1976–1999'
|
||||
ELSE '2000+'
|
||||
END
|
||||
ELSE 'Unknown'
|
||||
END AS age_band,
|
||||
COUNT(*)::int AS count
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId}
|
||||
GROUP BY age_band
|
||||
ORDER BY age_band;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getCountByEpcBand(
|
||||
portfolioId: number
|
||||
): Promise<EpcBandCount[]> {
|
||||
const result = await db.execute<EpcBandCount>(sql`
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
|
||||
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual,
|
||||
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY epc
|
||||
) q
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.epc = 'A' THEN 1
|
||||
WHEN q.epc = 'B' THEN 2
|
||||
WHEN q.epc = 'C' THEN 3
|
||||
WHEN q.epc = 'D' THEN 4
|
||||
WHEN q.epc = 'E' THEN 5
|
||||
WHEN q.epc = 'F' THEN 6
|
||||
WHEN q.epc = 'G' THEN 7
|
||||
ELSE 8 -- 'Unknown'
|
||||
END;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getEstimatedCounts(
|
||||
portfolioId: number
|
||||
): Promise<EstimatedCounts> {
|
||||
const result = await db.execute<EstimatedCounts>(sql`
|
||||
SELECT
|
||||
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated,
|
||||
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual
|
||||
FROM property_details_epc e
|
||||
WHERE e.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getCountByPropertyType(
|
||||
portfolioId: number
|
||||
): Promise<PropertyTypeCount[]> {
|
||||
const result = await db.execute<PropertyTypeCount>(sql`
|
||||
SELECT property_type AS type, COUNT(*)::int AS count
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId}
|
||||
GROUP BY property_type
|
||||
ORDER BY count DESC;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function loadBaselineMetrics(
|
||||
portfolioId: number
|
||||
): Promise<BaselineMetrics> {
|
||||
const [total, averages, totals, ageBands, epcBands, estimatedCounts] =
|
||||
await Promise.all([
|
||||
getPortfolioCounts(portfolioId),
|
||||
getAverages(portfolioId),
|
||||
getTotals(portfolioId),
|
||||
getCountByAgeBand(portfolioId),
|
||||
getCountByEpcBand(portfolioId),
|
||||
getEstimatedCounts(portfolioId),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
averages,
|
||||
totals,
|
||||
ageBands,
|
||||
epcBands,
|
||||
estimatedCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getScenarios(portfolioId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scenario)
|
||||
.where(eq(scenario.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
// Normalise response (only return what we need right now)
|
||||
return rows.map((s) => ({
|
||||
id: Number(s.id),
|
||||
name: s.name ?? `Scenario ${s.id}`,
|
||||
// we will compute the performance metrics in another function
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import {
|
||||
loadBaselineMetrics,
|
||||
getCountByPropertyType,
|
||||
} from "@/app/portfolio/[slug]/(portfolio)/reporting/loadBaselineMetrics";
|
||||
getScenarios,
|
||||
} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
|
||||
import { DashboardSummaryCards } from "@/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards";
|
||||
import { BreakdownChart } from "@/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart";
|
||||
import { EpcQualityCards } from "@/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards";
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
CONDITION_PLACEHOLDERS,
|
||||
FINANCIAL_PLACEHOLDERS,
|
||||
} from "@/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards";
|
||||
import { ReportingClientArea } from "./ReportingClientArea";
|
||||
|
||||
export default async function ReportingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -22,6 +24,14 @@ export default async function ReportingPage(props: {
|
|||
loadBaselineMetrics(Number(portfolioId)),
|
||||
getCountByPropertyType(Number(portfolioId)),
|
||||
]);
|
||||
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">
|
||||
|
|
@ -32,47 +42,12 @@ export default async function ReportingPage(props: {
|
|||
<div className="h-px bg-gray-200 mt-2" />
|
||||
</div>
|
||||
|
||||
{/* --- RETROFIT SECTION --- */}
|
||||
<SectionDivider
|
||||
title="Retrofit Summary"
|
||||
subtitle="High-level insights on performance, energy, and EPC quality."
|
||||
<ReportingClientArea
|
||||
baseline={baseline}
|
||||
propertyTypes={propertyTypes}
|
||||
scenarios={scenarios}
|
||||
portfolioId={Number(portfolioId)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">
|
||||
{/* LEFT SIDE: Portfolio Metrics */}
|
||||
<DashboardSummaryCards
|
||||
total={baseline.total}
|
||||
totals={baseline.totals}
|
||||
averages={baseline.averages}
|
||||
estimatedCounts={baseline.estimatedCounts}
|
||||
/>
|
||||
|
||||
{/* RIGHT SIDE: Chart */}
|
||||
<BreakdownChart
|
||||
epcBands={baseline.epcBands}
|
||||
ageBands={baseline.ageBands}
|
||||
propertyTypes={propertyTypes}
|
||||
/>
|
||||
|
||||
<EpcQualityCards
|
||||
estimatedCounts={baseline.estimatedCounts}
|
||||
total={baseline.total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --- CONDITION SECTION --- */}
|
||||
<SectionDivider
|
||||
title="Condition"
|
||||
subtitle="Awaabs Law, decent homes and compliance"
|
||||
/>
|
||||
<PlaceholderMetricCards items={CONDITION_PLACEHOLDERS} />
|
||||
|
||||
{/* --- FINANCIAL SECTION --- */}
|
||||
<SectionDivider
|
||||
title="Financial Overview"
|
||||
subtitle="Total bills, cost exposure, and potential funding pathways."
|
||||
/>
|
||||
<PlaceholderMetricCards items={FINANCIAL_PLACEHOLDERS} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import type { FC } from "react";
|
||||
|
||||
export interface ScenarioOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ScenarioSelectorProps {
|
||||
scenarios: ScenarioOption[];
|
||||
selected: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
|
||||
scenarios,
|
||||
selected,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">Scenario:</span>
|
||||
|
||||
<Select
|
||||
value={selected ? String(selected) : "none"}
|
||||
onValueChange={(val) => {
|
||||
if (val === "none") onChange(null);
|
||||
else onChange(Number(val));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-56 bg-white border-gray-200 shadow-sm">
|
||||
<SelectValue placeholder="Select scenario" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scenario (baseline only)</SelectItem>
|
||||
|
||||
{scenarios.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { ScenarioSelector } from "./scenarioSelector";
|
||||
|
||||
export function ScenarioSelectorWrapper({
|
||||
scenarios,
|
||||
portfolioId,
|
||||
selectedScenarioId,
|
||||
setSelectedScenarioId,
|
||||
}: {
|
||||
scenarios: { id: number; name: string }[];
|
||||
portfolioId: number;
|
||||
selectedScenarioId: number | null;
|
||||
setSelectedScenarioId: (id: number | null) => void;
|
||||
}) {
|
||||
// The ID we will eventually pass into React Query
|
||||
const activeContextId = useMemo(
|
||||
() => selectedScenarioId ?? portfolioId,
|
||||
[selectedScenarioId, portfolioId]
|
||||
);
|
||||
|
||||
function handleSelect(id: number | null) {
|
||||
setSelectedScenarioId(id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<ScenarioSelector
|
||||
scenarios={scenarios}
|
||||
selected={selectedScenarioId}
|
||||
onChange={handleSelect}
|
||||
/>
|
||||
|
||||
{selectedScenarioId !== null ? (
|
||||
<div className="text-xs text-gray-500">
|
||||
Scenario selected: {selectedScenarioId}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">Using portfolio baseline</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/app/portfolio/[slug]/(portfolio)/reporting/types.ts
Normal file
74
src/app/portfolio/[slug]/(portfolio)/reporting/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// src/server/reports/types.ts
|
||||
|
||||
export type AverageMetrics = {
|
||||
avg_sap: number | null;
|
||||
avg_carbon: number | null;
|
||||
avg_bills: number | null;
|
||||
avg_energy_consumption: number | null;
|
||||
};
|
||||
|
||||
export type TotalMetrics = {
|
||||
total_carbon: number | null;
|
||||
total_bills: number | null;
|
||||
};
|
||||
|
||||
export type AgeBandCount = {
|
||||
age_band: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type EpcBandCount = {
|
||||
epc: string;
|
||||
actual: number;
|
||||
estimated: number;
|
||||
};
|
||||
|
||||
export type EstimatedCounts = {
|
||||
estimated: number;
|
||||
actual: number;
|
||||
};
|
||||
|
||||
export type PropertyTypeCount = {
|
||||
type: string | null;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export interface BaselineMetrics {
|
||||
total: number;
|
||||
averages: AverageMetrics;
|
||||
totals: TotalMetrics;
|
||||
ageBands: AgeBandCount[];
|
||||
epcBands: EpcBandCount[];
|
||||
estimatedCounts: EstimatedCounts;
|
||||
}
|
||||
|
||||
export type MetricKey =
|
||||
| "totalHomes"
|
||||
| "avgSap"
|
||||
| "avgCarbon"
|
||||
| "avgBills"
|
||||
| "missingEpc";
|
||||
|
||||
export interface ScenarioSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ScenarioOverlayMetrics {
|
||||
avgSap?: {
|
||||
baseline: number;
|
||||
scenario: number;
|
||||
};
|
||||
avgCarbon?: {
|
||||
baseline: number;
|
||||
scenario: number;
|
||||
};
|
||||
avgBills?: {
|
||||
baseline: number;
|
||||
scenario: number;
|
||||
};
|
||||
valuation?: {
|
||||
baseline: number;
|
||||
scenario: number;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue