updating ui for new reporting page and started adding scenarios

This commit is contained in:
Khalim Conn-Kowlessar 2025-12-04 09:11:23 +00:00
parent 52d5a562c9
commit 1902e89ba8
12 changed files with 1115 additions and 43 deletions

View file

@ -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", {

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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} />
</>
);
}

View file

@ -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>
);
}

View file

@ -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 '19001929'
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '19301949'
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '19501975'
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '19761999'
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
}));
}

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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>
);
}

View 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;
};
}