diff --git a/src/app/db/schema/recommendations.ts b/src/app/db/schema/recommendations.ts index 507c5ab..faee042 100644 --- a/src/app/db/schema/recommendations.ts +++ b/src/app/db/schema/recommendations.ts @@ -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", { diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx new file mode 100644 index 0000000..93e3824 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/BreakdownChart.tsx @@ -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 ( + + +
+ + Property Breakdown + + + +
+ + {selected === "epc" && ( +

+ Where an EPC is missing, the EPC band shown here is a modelled + estimate. +

+ )} +
+ + + v.toString()} + stack={selected === "epc"} // enable stacked bars only for EPC mode + className="h-64" + /> + +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx new file mode 100644 index 0000000..ac910e1 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx @@ -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; color: string }>; + +const epcColors: Record = { + 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 ( + + {sign} {formatNumber(Math.abs(diff))} + + ); + } + + 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 ( +
+ {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 ( + + + + + + + + {c.title} + + + + + {/* --- BASELINE + SCENARIO SIDE BY SIDE --- */} +
+ {/* Baseline */} +
+ Baseline + + {c.key === "avgBills" ? `£${c.baseline}` : c.baseline} + + {c.units && ( + {c.units} + )} +
+ + {/* Scenario (if selected) */} + {hasScenario && ( +
+ Scenario + + {c.key === "avgBills" ? `£${c.scenario}` : c.scenario} + + + {c.delta &&
{c.delta}
} +
+ )} +
+ + {/* Totals */} + {c.totalValue && ( +
{c.totalValue}
+ )} + + {/* Missing EPC Bar */} + {c.key === "missingEpc" && ( +
+
+
+ )} + + + +

{c.subtitle}

+
+ + ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx new file mode 100644 index 0000000..958ff5b --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx @@ -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 ( +
+ {cards.map((c) => { + const Icon = c.icon; + return ( + + +
+ + + +
+ + {c.title} + +
+ + +
+ {c.value} +
+ + {/* Correct mini bar per card */} +
+
+
+ + + +

{c.subtitle}

+
+ + ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards.tsx new file mode 100644 index 0000000..e0caeeb --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/PlaceholderMetricCards.tsx @@ -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 ( +
+ {items.map((c) => { + const Icon = c.icon; + + return ( + + +
+ + + +
+ + + {c.title} + +
+ + +
+ Data not provided +
+
+ + +

{c.subtitle}

+
+
+ ); + })} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx new file mode 100644 index 0000000..8057fde --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -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( + null + ); + + const [scenarioMetrics, setScenarioMetrics] = useState(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 ( + <> + + + {/* --- RETROFIT SECTION --- */} + + +
+ + + + +
+ +
+
+ + {/* --- CONDITION SECTION --- */} + + + + {/* --- FINANCIAL SECTION --- */} + + + + ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider.tsx new file mode 100644 index 0000000..e76e9c6 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { motion } from "framer-motion"; + +export function SectionDivider({ + title, + subtitle, +}: { + title: string; + subtitle?: string; +}) { + return ( +
+ {/* Title */} +
+

+ {title} +

+ + {subtitle &&

{subtitle}

} +
+ + {/* Animated gradient line */} + +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts new file mode 100644 index 0000000..e6e4862 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -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 { + 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 { + const result = await db.execute(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 { + const result = await db.execute(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 { + const result = await db.execute(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 { + const result = await db.execute(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 { + const result = await db.execute(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 { + const result = await db.execute(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 { + 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 + })); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx index ba4dceb..290f14f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/page.tsx @@ -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 (
@@ -32,47 +42,12 @@ export default async function ReportingPage(props: {
- {/* --- RETROFIT SECTION --- */} - - -
- {/* LEFT SIDE: Portfolio Metrics */} - - - {/* RIGHT SIDE: Chart */} - - - -
- - {/* --- CONDITION SECTION --- */} - - - - {/* --- FINANCIAL SECTION --- */} - -
); } diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelector.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelector.tsx new file mode 100644 index 0000000..37b0aef --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelector.tsx @@ -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 = ({ + scenarios, + selected, + onChange, +}) => { + return ( +
+ Scenario: + + +
+ ); +}; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelectorWrapper.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelectorWrapper.tsx new file mode 100644 index 0000000..03d788c --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/scenarioSelectorWrapper.tsx @@ -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 ( +
+ + + {selectedScenarioId !== null ? ( +
+ Scenario selected: {selectedScenarioId} +
+ ) : ( +
Using portfolio baseline
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts new file mode 100644 index 0000000..c07fc57 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/types.ts @@ -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; + }; +}