Merge pull request #145 from Hestia-Homes/new-reporting

New reporting - 90% complete on new UI without direct link
This commit is contained in:
KhalimCK 2025-12-08 23:31:37 +08:00 committed by GitHub
commit 6896307af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 8523 additions and 3538 deletions

View file

@ -130,4 +130,7 @@ In our terraform stack, we have a module called `s3_presignable_bucket` which co
We will generate a pre-signed url and then make a post request to that endpoint to store that data to s3. Part of that process is the creation of an AWS IAM role which contains
the permission set to access the bucket, `rerofit-plan-inputs-<stage>`. The name of this IAM role is `s3_presign_role_<stage>` and for our NextJS application, as it's hosted outside of AWS (for the moment), we need to generate a set of access credentials to give the application access to this bucket. The access key and secret key are automatically generated and stored in AWS secrets manager under `dev/presign_frontend/access_key` and `dev/presign_frontend/secret_key` and need to be set in the environment for the pre-sign api to store csv data to aws.
#
Quick wins:
- [] Sign in butotn in microsoft disappear!
- [] Frequently asked questions page

5524
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "/workspaces/assessment-model/node_modules/.bin/next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -53,10 +53,11 @@
"eslint-config-next": "13.4.3",
"framer-motion": "^12.23.24",
"lucide-react": "^0.233.0",
"next": "^15.4.2",
"next": "^15.5.7",
"next-auth": "^4.22.1",
"next-axiom": "^1.9.2",
"next-themes": "^0.3.0",
"nodemailer": "^7.0.11",
"pg": "^8.11.1",
"postcss": "^8.5.6",
"react": "18.3.1",

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

@ -52,7 +52,7 @@ export default function EmailSignInButton({
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Enter your email"
placeholder="Enter email"
required
className="flex-1 h-10 rounded-lg border-gray-300"
/>

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

@ -1,7 +1,6 @@
import { getServerSession } from "next-auth/next";
import { AuthOptions } from "./api/auth/[...nextauth]/authOptions";
import GoogleSignInButton from "./components/signin/GoogleSignInButton";
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
import EmailSignInButton from "./components/signin/EmailSignInButton";
import { redirect } from "next/navigation";
import Image from "next/image";
@ -40,10 +39,10 @@ export default async function Home(props: {
width={300}
/>
<h1 className="text-4xl font-medium text-brandblue mb-8 text-center">
Sign in to your account
Your portfolios, managed easily.
</h1>
<div className="text-brandmidblue text-lg mb-4">
Start managing your portfolios
Well email you a login link no password required.
</div>
<div className="mb-2 min-w-[19rem]">
@ -51,10 +50,7 @@ export default async function Home(props: {
<EmailSignInButton error={error} />
</div>
<div className="text-md"> Sign in with a Social Account</div>
<div className="mb-2">
<MicrosoftSignInButton />
</div>
<GoogleSignInButton />
<GoogleSignInButton />
</div>
</section>
</div>

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.");
}