merge with main

This commit is contained in:
Jun-te Kim 2025-12-08 15:34:03 +00:00
commit 54fa3e1ada
24 changed files with 6339 additions and 184 deletions

4
package-lock.json generated
View file

@ -45,8 +45,8 @@
"eslint-config-next": "13.4.3",
"framer-motion": "^12.23.24",
"lucide-react": "^0.233.0",
"next": "^15.4.8",
"next-auth": "^4.24.13",
"next": "^15.5.7",
"next-auth": "^4.22.1",
"next-axiom": "^1.9.2",
"next-themes": "^0.3.0",
"nodemailer": "^7.0.11",

View file

@ -51,8 +51,8 @@
"eslint-config-next": "13.4.3",
"framer-motion": "^12.23.24",
"lucide-react": "^0.233.0",
"next": "^15.4.8",
"next-auth": "^4.24.13",
"next": "^15.5.7",
"next-auth": "^4.22.1",
"next-axiom": "^1.9.2",
"next-themes": "^0.3.0",
"nodemailer": "^7.0.11",

View file

@ -0,0 +1,121 @@
import { db } from "@/app/db/db";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { sapToEpc } from "@/app/utils";
type PlanRow = {
id: bigint;
post_sap_points: number | null;
post_co2_emissions: number | null;
post_energy_bill: number | null;
post_energy_consumption: number | null;
valuation_post_retrofit: number | null;
valuation_increase: number | null;
co2_savings: number | null;
energy_bill_savings: number | null;
energy_consumption_savings: number | null;
};
export async function GET(
request: NextRequest,
props: { params: Promise<{ portfolioId: string; scenarioId: string }> }
) {
console.log("In the request ");
const { portfolioId, scenarioId } = await props.params;
const pid = BigInt(portfolioId);
const sid = BigInt(scenarioId);
// Fetch all plans
const planRows = await db.execute(sql`
SELECT
id,
post_sap_points,
post_co2_emissions,
post_energy_bill,
post_energy_consumption,
valuation_post_retrofit,
valuation_increase,
co2_savings,
energy_bill_savings,
energy_consumption_savings
FROM plan
WHERE portfolio_id = ${pid}
AND scenario_id = ${sid};
`);
const plans = planRows.rows as PlanRow[];
if (plans.length === 0) {
return NextResponse.json(
{ error: "No plans found for this scenario" },
{ status: 404 }
);
}
const planIds = plans.map((p) => p.id);
// Total funding
const planIdArray = sql`ARRAY[${sql.join(planIds, sql`, `)}]::bigint[]`;
const fundingRows = await db.execute(sql`
SELECT
SUM(COALESCE(project_funding, 0) + COALESCE(total_uplift, 0))::float AS total_funding
FROM funding_package
WHERE plan_id = ANY(${planIdArray});
`);
// Averages + totals
const n = plans.length;
const avg_sap = plans.reduce((s, r) => s + (r.post_sap_points ?? 0), 0) / n;
const avg_carbon =
plans.reduce((s, r) => s + (r.post_co2_emissions ?? 0), 0) / n;
const avg_bills =
plans.reduce((s, r) => s + (r.post_energy_bill ?? 0), 0) / n;
const total_carbon = plans.reduce(
(s, r) => s + (r.post_co2_emissions ?? 0),
0
);
const total_bills = plans.reduce((s, r) => s + (r.post_energy_bill ?? 0), 0);
// Financial
const totalCost = 0;
const contingency = 0;
const funding = Number(fundingRows.rows[0]?.total_funding ?? 0);
const netCost = totalCost - funding;
// NEW — scenario EPC band counts
const scenario_epc_counts: Record<string, number> = {
A: 0,
B: 0,
C: 0,
D: 0,
E: 0,
F: 0,
G: 0,
Unknown: 0,
};
for (const p of plans) {
const band = sapToEpc(p.post_sap_points);
scenario_epc_counts[band] += 1;
}
return NextResponse.json({
avg_sap: avg_sap.toFixed(1),
avg_carbon,
avg_bills,
total_carbon,
total_bills,
total_cost: totalCost,
contingency,
total_funding: funding,
net_cost: netCost,
net_cost_per_unit: n > 0 ? netCost / n : 0,
n_units: n,
// NEW DATA for Overlay
scenario_epc_counts,
});
}

View file

@ -0,0 +1,10 @@
ALTER TABLE "plan" ADD COLUMN "post_sap_points" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_epc_rating" "epc";--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_co2_emissions" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "co2_savings" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_energy_bill" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "energy_bill_savings" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "post_energy_consumption" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "energy_consumption_savings" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "valuation_post_retrofit" real;--> statement-breakpoint
ALTER TABLE "plan" ADD COLUMN "valuation_increase" real;

File diff suppressed because it is too large Load diff

View file

@ -939,6 +939,13 @@
"when": 1764403077454,
"tag": "0133_calm_talkback",
"breakpoints": true
},
{
"idx": 134,
"version": "7",
"when": 1764886152458,
"tag": "0134_lying_lester",
"breakpoints": true
}
]
}

View file

@ -1,4 +1,4 @@
import { property } from "./property";
import { property, epcEnum } from "./property";
import { goalEnum, portfolio } from "./portfolio";
import {
bigserial,
@ -83,21 +83,66 @@ 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(),
totalCost: real("total_cost"),
contingency: real("contingency"),
// ─────────────────────────────────────────────────────────
// 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 consumption (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

@ -5,7 +5,7 @@ import * as React from "react";
export default async function PortfolioLayout(props: {
children: React.ReactNode;
params: Promise<{ slug: string; propertyId: string }>;
params: Promise<{ slug: string }>;
}) {
const params = await props.params;

View file

@ -0,0 +1,139 @@
"use client";
import { useState, useMemo } from "react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/app/shadcn_components/ui/card";
import { BarChart } from "@tremor/react";
import type { CustomTooltipProps } 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,
scenarioEpcBands,
}: {
epcBands: EpcBandCount[];
ageBands: AgeBandCount[];
propertyTypes: PropertyTypeCount[];
scenarioEpcBands?: Record<string, number>;
}) {
const [selected, setSelected] = useState("epc");
const friendlyKeys = {
actual: "Actual EPCs",
estimated: "Estimated EPCs",
scenario: "Scenario result",
};
const chartData = useMemo(() => {
if (selected !== "epc") {
return selected === "age"
? ageBands.map((d) => ({ label: d.age_band, Count: d.count }))
: propertyTypes.map((d) => ({
label: d.type ?? "Unknown",
Count: d.count,
}));
}
const rows: any[] = [];
for (const d of epcBands) {
const epc = d.epc ?? "Unknown";
const scenarioValue = scenarioEpcBands?.[epc] ?? 0;
// Baseline (stacked)
rows.push({
label: `${epc} (baseline)`,
[friendlyKeys.actual]: d.actual ?? 0,
[friendlyKeys.estimated]: d.estimated ?? 0,
[friendlyKeys.scenario]: 0,
});
// Scenario (single bar)
rows.push({
label: `${epc} (scenario)`,
[friendlyKeys.actual]: 0,
[friendlyKeys.estimated]: 0,
[friendlyKeys.scenario]: scenarioValue,
});
}
return rows;
}, [selected, epcBands, ageBands, propertyTypes, scenarioEpcBands]);
const categories =
selected === "epc"
? [friendlyKeys.actual, friendlyKeys.estimated, friendlyKeys.scenario]
: ["Count"];
const colors =
selected === "epc" ? ["#14163d", "#3943b7", "emerald"] : ["#2d348f"];
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 />
</SelectTrigger>
<SelectContent>
<SelectItem value="epc">EPC Band</SelectItem>
<SelectItem value="age">Age Band</SelectItem>
<SelectItem value="type">Property Type</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<BarChart
data={chartData}
index="label"
categories={categories}
colors={colors}
valueFormatter={(v) => v.toString()}
stack={selected === "epc"}
customTooltip={MyTooltip}
className="h-64"
/>
</CardContent>
</Card>
);
}
function MyTooltip({ payload }: CustomTooltipProps) {
if (!payload || payload.length === 0) return null;
return (
<div className="rounded-md bg-white shadow-lg border border-gray-200 px-3 py-2 text-xs text-gray-700">
{payload.map((p) => (
<div
key={p.dataKey}
className="flex justify-between gap-4 items-center"
>
<span>{p.dataKey}:</span>
<span className="font-medium">{p.value}</span>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,256 @@
"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",
};
function hasOverlay(
overlay: ScenarioOverlayMetrics | undefined
): overlay is ScenarioOverlayMetrics {
return overlay !== undefined;
}
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);
const overlay = scenarioOverlay ?? undefined;
const hasScenario = hasOverlay(overlay);
function deltaLabel(baseline: number, scenario: number) {
const b = Number(baseline);
const s = Number(scenario);
const diff = s - b;
if (!isFinite(diff) || diff === 0) return null;
const sign = diff > 0 ? "▲" : "▼";
const color = diff > 0 ? "text-red-600" : "text-emerald-600";
return (
<span className={`text-sm font-medium ${color}`}>
{sign} {Math.abs(diff).toFixed(2)}
</span>
);
}
const cards = [
{
key: "totalHomes",
title: "Number of Homes",
baseline: total,
scenario: null,
baselineTotal: undefined,
scenarioTotal: undefined,
units: "",
subtitle: "Total properties in this portfolio.",
},
{
key: "avgSap",
title: "Average EPC Rating",
baseline: `${averageCurrentEpc} (${Math.round(averages.avg_sap ?? 0)} pts)`,
scenario:
overlay?.avgSap &&
`${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`,
baselineTotal: undefined,
scenarioTotal: undefined,
subtitle: "Current SAP rating across all properties.",
isEpc: true,
},
{
key: "avgCarbon",
title: "Carbon Emissions",
baseline: formatNumber(averages.avg_carbon ?? 0),
scenario: overlay?.avgCarbon && formatNumber(overlay.avgCarbon.scenario),
units: "tCO₂e /home",
baselineTotal: totals.total_carbon ?? 0,
scenarioTotal: overlay?.avgCarbon?.scenarioTotal,
subtitle: "Average annual CO₂ output per home.",
delta:
hasScenario && overlay?.avgCarbon
? deltaLabel(overlay.avgCarbon.baseline, overlay.avgCarbon.scenario)
: null,
},
{
key: "avgBills",
title: "Energy Bills",
baseline: formatNumber(averages.avg_bills ?? 0),
scenario: overlay?.avgBills && formatNumber(overlay.avgBills.scenario),
units: "/ home",
baselineTotal: totals.total_bills ?? 0,
scenarioTotal: overlay?.avgBills?.scenarioTotal,
subtitle: "Estimated annual energy bills.",
delta:
hasScenario && overlay?.avgBills
? deltaLabel(overlay.avgBills.baseline, overlay.avgBills.scenario)
: null,
},
];
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;
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-2">
{/* BASELINE + SCENARIO ROW */}
<div
className={`flex ${
hasScenario ? "justify-between" : "justify-start"
} items-start`}
>
{/* BASELINE COLUMN */}
<div className="flex flex-col">
<span className="text-xs text-gray-500">Baseline</span>
<div className="flex items-baseline gap-2">
<span
className={
c.isEpc
? `text-3xl font-semibold ${epcColors[averageCurrentEpc || "Unknown"]}`
: "text-3xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-brandblue to-midblue"
}
>
{c.key === "avgBills" ? `£${c.baseline}` : c.baseline}
</span>
{/* units next to baseline average */}
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
{/* Baseline total */}
{c.baselineTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
)}
</div>
{/* SCENARIO COLUMN */}
{hasScenario && c.scenario && (
<div className="flex flex-col text-right">
<span className="text-xs text-gray-500">Scenario</span>
{/* average + delta + units row */}
<div className="flex items-baseline justify-end gap-2">
<span
className={
c.isEpc
? `text-2xl font-semibold ${
epcColors[
sapToEpc(
overlay?.avgSap?.scenario ??
(averages.avg_sap || 0)
) || "Unknown"
]
}`
: "text-2xl font-semibold text-brandblue"
}
>
{c.key === "avgBills" ? `£${c.scenario}` : c.scenario}
</span>
{c.delta && <span>{c.delta}</span>}
</div>
{/* Scenario total */}
{c.scenarioTotal !== undefined && (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
)}
</div>
)}
</div>
{/* Missing EPC bar */}
{c.key === "missingEpc" && (
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
className="h-2 rounded-full bg-red-500"
style={{ width: `${missingEpcPercent}%` }}
/>
</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,198 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
import { DashboardSummaryCards } from "./DashboardSummaryCards";
import { BreakdownChart } from "./BreakdownChart";
import { EpcQualityCards } from "./EpcQualityCards";
import { ScenarioFinancialDrawer } from "./ScenarioFinancialDrawer";
import { SectionDivider } from "@/app/portfolio/[slug]/(portfolio)/reporting/SectionDivider";
import {
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;
}
// ----------------------------------------
// Fetcher for scenario API route
// ----------------------------------------
async function fetchScenarioReport({
portfolioId,
scenarioId,
}: {
portfolioId: number;
scenarioId: number;
}) {
const res = await fetch(
`/api/portfolio/${portfolioId}/scenario/${scenarioId}/metrics`
);
if (!res.ok) {
console.error("Failed to fetch scenario report:", await res.text());
throw new Error("Failed to load scenario report");
}
return res.json();
}
export function ReportingClientArea({
baseline,
propertyTypes,
scenarios,
portfolioId,
}: ReportingClientAreaProps) {
const [selectedScenarioId, setSelectedScenarioId] = useState<number | null>(
null
);
const drawerOpen = Boolean(selectedScenarioId);
// ----------------------------------------
// React Query: fetch scenario metrics
// ----------------------------------------
const {
data: scenarioData,
isLoading,
isError,
} = useQuery({
queryKey: ["scenario-report", portfolioId, selectedScenarioId],
queryFn: () =>
fetchScenarioReport({
portfolioId,
scenarioId: selectedScenarioId!,
}),
enabled: !!selectedScenarioId, // only run when scenario selected
});
// ----------------------------------------
// Build overlay for Dashboard Summary cards
// ----------------------------------------
const scenarioOverlay = scenarioData
? {
avgSap: {
baseline: baseline.averages.avg_sap ?? 0,
scenario: Number(scenarioData.avg_sap),
},
avgCarbon: {
baseline: Number(baseline.averages.avg_carbon ?? 0),
scenario: Number(scenarioData.avg_carbon),
baselineTotal: Number(baseline.totals.total_carbon ?? 0),
scenarioTotal: Number(scenarioData.total_carbon ?? 0),
},
avgBills: {
baseline: baseline.averages.avg_bills ?? 0,
scenario: scenarioData.avg_bills,
baselineTotal: baseline.totals.total_bills ?? 0,
scenarioTotal: scenarioData.total_bills,
},
valuation: { baseline: null, scenario: null },
scenarioEpcBands: scenarioData.scenario_epc_counts,
}
: null;
// ----------------------------------------
// Financial drawer values (from API)
// ----------------------------------------
const scenarioFinancial = scenarioData
? {
totalCost: scenarioData.total_cost,
contingency: scenarioData.contingency,
funding: scenarioData.total_funding,
costPerSap:
scenarioData.total_cost > 0
? scenarioData.total_cost / scenarioData.avg_sap
: 0,
costPerCo2:
scenarioData.total_cost > 0
? scenarioData.total_cost / scenarioData.total_carbon
: 0,
netCost: scenarioData.net_cost,
netCostPerUnit: scenarioData.net_cost_per_unit,
nUnits: scenarioData.n_units,
}
: null;
// Baseline stays baseline
const activeMetrics = baseline;
return (
<>
<ScenarioSelectorWrapper
scenarios={scenarios}
portfolioId={portfolioId}
selectedScenarioId={selectedScenarioId}
setSelectedScenarioId={setSelectedScenarioId}
/>
{/* LOADING + ERROR STATES */}
{isLoading && selectedScenarioId && (
<div className="text-sm text-gray-500 mt-2">Loading scenario</div>
)}
{isError && (
<div className="text-sm text-red-500 mt-2">
Could not load scenario data.
</div>
)}
{/* --- RETROFIT SECTION --- */}
<SectionDivider
title="Retrofit Summary"
subtitle="High-level insights on performance, energy, and EPC quality."
/>
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioFinancial} />
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
<DashboardSummaryCards
total={activeMetrics.total}
totals={activeMetrics.totals}
averages={activeMetrics.averages}
estimatedCounts={activeMetrics.estimatedCounts}
scenarioOverlay={scenarioOverlay}
/>
<BreakdownChart
epcBands={activeMetrics.epcBands}
ageBands={activeMetrics.ageBands}
propertyTypes={propertyTypes}
scenarioEpcBands={scenarioOverlay?.scenarioEpcBands}
/>
<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,147 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { formatNumber } from "@/app/utils";
// Premium Icons
import {
Banknote,
ShieldAlert,
PiggyBank,
Scale,
Gauge,
Factory,
Home,
Users,
} from "lucide-react";
export function ScenarioFinancialDrawer({
open,
metrics,
}: {
open: boolean;
metrics: any | null;
}) {
return (
<AnimatePresence initial={false}>
{open && metrics && (
<motion.div
key="drawer"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="rounded-lg border border-gray-200 bg-white shadow-sm mt-4 p-6">
<h3 className="text-lg font-semibold text-brandblue mb-4">
Scenario Financial Summary
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<Metric
label="Total Cost"
value={`£${formatNumber(metrics.totalCost)}`}
icon={Banknote}
color="text-brandblue"
bg="bg-brandblue/20"
/>
<Metric
label="Contingency"
value={`£${formatNumber(metrics.contingency)}`}
icon={ShieldAlert}
color="text-amber-600"
bg="bg-amber-200/40"
/>
<Metric
label="Funding"
value={`£${formatNumber(metrics.funding)}`}
icon={PiggyBank}
color="text-emerald-600"
bg="bg-emerald-200/40"
/>
<Metric
label="Net Cost"
value={`£${formatNumber(metrics.netCost)}`}
icon={Scale}
color="text-red-600"
bg="bg-red-200/40"
/>
<Metric
label="Cost per SAP point"
value={`£${formatNumber(metrics.costPerSap)}`}
icon={Gauge}
color="text-purple-600"
bg="bg-purple-200/40"
/>
<Metric
label="Cost per tonne CO₂"
value={`£${formatNumber(metrics.costPerCo2)}`}
icon={Factory}
color="text-slate-700"
bg="bg-slate-200/40"
/>
<Metric
label="Net Cost per Unit"
value={`£${formatNumber(metrics.netCostPerUnit)}`}
icon={Home}
color="text-sky-600"
bg="bg-sky-200/40"
/>
<Metric
label="Units Upgraded"
value={metrics.nUnits}
icon={Users}
color="text-brandblue"
bg="bg-brandblue/20"
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
function Metric({
label,
value,
icon: Icon,
color,
bg,
}: {
label: string;
value: string | number;
icon: any;
color: string;
bg: string;
}) {
return (
<div
className="group flex flex-col rounded-lg border border-gray-200
bg-gradient-to-br from-white to-gray-50
p-3 shadow-sm hover:shadow-md
hover:border-brandblue/30 transition-all"
>
<div className="flex items-center gap-2 mb-1">
{/* coloured icon background */}
<div
className={`p-1.5 rounded-md ${bg} group-hover:opacity-80 transition`}
>
<Icon className={`h-4 w-4 ${color}`} />
</div>
<span className="text-[10px] uppercase tracking-wide font-semibold text-gray-500">
{label}
</span>
</div>
<span
className="text-lg md:text-xl font-semibold text-gray-900
group-hover:text-brandblue transition truncate"
>
{value}
</span>
</div>
);
}

View file

@ -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,14 +1,37 @@
import {
loadBaselineMetrics,
getCountByPropertyType,
getScenarios,
} from "@/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions";
import { ReportingClientArea } from "./ReportingClientArea";
export default async function ReportingPage(props: {
params: Promise<{ slug: string }>;
}) {
const params = await props.params;
const portfolioId = params.slug;
const [baseline, propertyTypes] = await Promise.all([
loadBaselineMetrics(Number(portfolioId)),
getCountByPropertyType(Number(portfolioId)),
]);
const scenarios = await getScenarios(Number(portfolioId));
return (
<>
<div className="flex justify-center">
<div>Reporting Page for portfolio: {portfolioId}</div>
<div className="max-w-8xl mx-auto px-6 pb-10 space-y-4 pt-4">
<div className="mb-6">
<header className="text-3xl font-semibold text-brandblue">
Portfolio Overview
</header>
<div className="h-px bg-gray-200 mt-2" />
</div>
</>
<ReportingClientArea
baseline={baseline}
propertyTypes={propertyTypes}
scenarios={scenarios}
portfolioId={Number(portfolioId)}
/>
</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,49 @@
"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]
// );
const [selectedScenarioName, setSelectedScenarioName] = useState<
string | null
>(null);
function handleSelect(id: number | null) {
setSelectedScenarioId(id);
const scenario = scenarios.find((s) => s.id === id);
setSelectedScenarioName(scenario ? scenario.name : null);
}
return (
<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: {selectedScenarioName}
</div>
) : (
<div className="text-xs text-gray-400">Using portfolio baseline</div>
)}
</div>
);
}

View file

@ -0,0 +1,81 @@
// 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;
baselineTotal: number;
scenarioTotal: number;
};
avgBills?: {
baseline: number;
scenario: number;
baselineTotal: number;
scenarioTotal: number;
};
valuation?: {
baseline: number | null;
scenario: number | null;
};
}

View file

@ -4,7 +4,6 @@ import BackToPortfolioButton from "@/app/components/building-passport/BackToPort
import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
// import "@tremor/react/dist/esm/tremor.css";
function EstimatedDataNotification() {
return (
<div className="flex items-center text-brandmidblue mt-4">
@ -27,7 +26,6 @@ export default async function DashboardLayout(props: {
const propertyId = params.propertyId ?? "";
const portfolioId = params.slug ?? "";
// The layout is a server component by default so we can fetch meta data here
const propertyMeta = await getPropertyMeta(params.propertyId);

View file

@ -1,25 +0,0 @@
import BackToPortfolio from "@/app/components/portfolio/BackToPortfolio";
export default async function Layout(
props: {
children: React.ReactNode;
params: Promise<{ slug: string; lmkKey: string }>;
}
) {
const params = await props.params;
const {
children
} = props;
const portfolioId = params.slug;
return (
<section>
<div className="mt-2">
<BackToPortfolio portfolioId={portfolioId} />
</div>
{children}
</section>
);
}

View file

@ -1,145 +0,0 @@
"use client";
import { useState, use } from "react";
import SearchPostcodeButton from "../../../components/search/SearchPostcodeButton";
import { useRouter } from "next/navigation";
import { SearchData, SearchResult } from "@/types/epc";
import SelectAddressButton from "../../../components/search/SelectAddressButton";
import ToggleAddressButton from "../../../components/search/ToggleAddressButton";
const defaultToggleClass =
"mb-1 block max-w-sm rounded-lg border border-gray-200 bg-white p-6 shadow hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700";
const toggledButtonClass =
"text-white mb-1 block max-w-sm rounded-lg border border-gray-200 bg-brandblue p-6 shadow hover:bg-hoverblue dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700";
export default function Search(props: { params: Promise<{ slug: string }> }) {
const params = use(props.params);
const [postcode, setPostcode] = useState("");
const [buttonDisabled, setButtonDisabled] = useState(true);
const [data, setData] = useState<null | SearchData>(null);
const [address, setAddress] = useState("");
const [addressButtonDisabled, setAddressButtonDisabled] = useState(true);
// Keep track of which lmk-key is currently toggled. Initially, none
// are toggled
const [currentlyToggled, setCurrentlyToggled] = useState("");
const router = useRouter();
const handleSubmit = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
fetchData().catch((error) => {
console.log(error);
});
}
};
const handlePostcodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// If the text is empty, disable the button, otherwise enable it
setPostcode(e.target.value);
if (e.target.value) {
setButtonDisabled(false);
return;
}
setButtonDisabled(true);
return;
};
// TODO: This might take a moment to fetch data, add a loading state?
async function fetchData() {
// TODO - add strict typing to the api response
setCurrentlyToggled("");
setAddressButtonDisabled(true);
try {
const response = await fetch(`/api/search?postcode=${postcode}`);
const data = (await response.json()) as SearchData;
setData(data);
} catch (error) {
console.error("Error fetching data:", error);
}
}
const redirectToProperty = () => {
if (data === null) return;
const res = data.rows.find(
(row: SearchResult) => row["lmk-key"] === currentlyToggled
) as SearchResult;
const portfolioId = params.slug;
const lmkKey = res["lmk-key"];
router.push(
`/portfolio/${portfolioId}/property/${lmkKey}?postcode=${postcode}`
);
};
const submitProps = {
buttonDisabled: buttonDisabled,
onClickFunc: fetchData,
};
return (
<div className="flex min-h-screen flex-col items-center pt-8">
<div className="w-1/2 flex flex-col justify-center bg-gray-100 rounded p-4 shadow-md">
<div className="w-full flex justify-center text-center mb-7 text-gray-700">
We will search for the most recent data about your property, so we can
begin to build a profile of the works that can be done and the impact
that will have
</div>
<form className="w-full flex flex-col items-center">
<label
className="mb-2 block text-sm font-bold text-gray-700 text-center"
htmlFor="search"
>
Enter your postcode to get started
</label>
<div className="flex justify-center">
<input
id="search"
className="w-full appearance-none rounded border-2 border-gray-200 bg-gray-200 px-8 py-2 leading-tight text-gray-700 focus:border-brandmidblue focus:bg-white focus:outline-none"
type="text"
placeholder="Enter your postcode"
onChange={handlePostcodeChange}
onKeyDown={handleSubmit}
/>
</div>
<SearchPostcodeButton {...submitProps} />
</form>
{data && (
<div>
<div className="flex justify-center mt-7">
Scroll to find your address from the list
</div>
<ul className="mt-3 overflow-y-auto flex flex-col items-center max-h-60">
{data.rows.map((row: SearchResult) => {
return (
<ToggleAddressButton
key={row["lmk-key"]}
rowKey={row["lmk-key"]}
setButtonDisabled={setAddressButtonDisabled}
setAddress={setAddress}
setCurrentlyToggled={setCurrentlyToggled}
address={row.address}
toggleClassName={
currentlyToggled === row["lmk-key"]
? toggledButtonClass
: defaultToggleClass
}
/>
);
})}
</ul>
<div className="flex justify-end">
<SelectAddressButton
buttonDisabled={addressButtonDisabled}
redirectFunc={redirectToProperty}
/>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -92,7 +92,11 @@ export const serializeBigInt = (_: any, value: any): string | any => {
return typeof value === "bigint" ? value.toString() : value;
};
export function sapToEpc(sapPoints: number): string {
export function sapToEpc(sapPoints: number | null): string {
if (sapPoints === null) {
return "Unknown";
}
if (sapPoints < 0) {
throw new Error("SAP points should be above 0.");
}