mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
merge with main
This commit is contained in:
commit
54fa3e1ada
24 changed files with 6339 additions and 184 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
10
src/app/db/migrations/0134_lying_lester.sql
Normal file
10
src/app/db/migrations/0134_lying_lester.sql
Normal 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;
|
||||
4758
src/app/db/migrations/meta/0134_snapshot.json
Normal file
4758
src/app/db/migrations/meta/0134_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -939,6 +939,13 @@
|
|||
"when": 1764403077454,
|
||||
"tag": "0133_calm_talkback",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 134,
|
||||
"version": "7",
|
||||
"when": 1764886152458,
|
||||
"tag": "0134_lying_lester",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import { FileQuestion, BarChart3 } from "lucide-react";
|
||||
import type { EstimatedCounts } from "./types";
|
||||
|
||||
export function EpcQualityCards({
|
||||
estimatedCounts,
|
||||
total,
|
||||
}: {
|
||||
estimatedCounts: EstimatedCounts;
|
||||
total: number;
|
||||
}) {
|
||||
const missing = estimatedCounts.estimated;
|
||||
const pctMissing = total > 0 ? (missing / total) * 100 : 0;
|
||||
const pctValid = 100 - pctMissing;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
key: "missing",
|
||||
title: "Homes Without an EPC",
|
||||
icon: FileQuestion,
|
||||
color: "text-red-600",
|
||||
value: missing,
|
||||
subtitle: `${pctMissing.toFixed(1)}% missing EPCs (predicted only)`,
|
||||
barColor: "bg-red-500",
|
||||
barWidth: pctMissing,
|
||||
},
|
||||
{
|
||||
key: "quality",
|
||||
title: "EPC Data Coverage",
|
||||
icon: BarChart3,
|
||||
color: "text-brandblue",
|
||||
value: `${pctValid.toFixed(1)}%`,
|
||||
subtitle: "Percentage of homes with a valid EPC.",
|
||||
barColor: "bg-brandblue",
|
||||
barWidth: pctValid,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<Card
|
||||
key={c.key}
|
||||
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-red-50/20 hover:shadow-md hover:-translate-y-0.5 transition-all"
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-1">
|
||||
<div className="p-1.5 rounded-md bg-gray-100">
|
||||
<motion.div whileHover={{ scale: 1.1 }} className="p-1">
|
||||
<Icon className={`h-4 w-4 ${c.color}`} />
|
||||
</motion.div>
|
||||
</div>
|
||||
<CardTitle className="text-md font-medium text-gray-600">
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col pb-2">
|
||||
<div className="text-2xl font-semibold text-brandblue">
|
||||
{c.value}
|
||||
</div>
|
||||
|
||||
{/* Correct mini bar per card */}
|
||||
<div className="w-full mt-3 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${c.barColor}`}
|
||||
style={{ width: `${c.barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/app/shadcn_components/ui/card";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AlertTriangle,
|
||||
FileQuestion,
|
||||
ClipboardList,
|
||||
ClipboardX,
|
||||
PoundSterling,
|
||||
Home,
|
||||
} from "lucide-react";
|
||||
|
||||
type PlaceholderCard = {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const CONDITION_PLACEHOLDERS = [
|
||||
{
|
||||
key: "awwabs",
|
||||
title: "Awaabs Law Warnings",
|
||||
subtitle: "Severe hazards related to damp & mould.",
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600",
|
||||
},
|
||||
{
|
||||
key: "cat12",
|
||||
title: "Category 1 & 2 Hazards",
|
||||
subtitle: "Safety risks identified under HHSRS.",
|
||||
icon: AlertTriangle,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
{
|
||||
key: "noStockSurvey",
|
||||
title: "Missing Stock Condition Survey",
|
||||
subtitle: "Properties without a structural/condition survey.",
|
||||
icon: ClipboardX,
|
||||
color: "text-brandblue",
|
||||
},
|
||||
{
|
||||
key: "noDecentHomes",
|
||||
title: "Missing Decent Homes Survey",
|
||||
subtitle: "Properties lacking a Decent Homes standard review.",
|
||||
icon: ClipboardList,
|
||||
color: "text-brandblue",
|
||||
},
|
||||
];
|
||||
|
||||
export const FINANCIAL_PLACEHOLDERS = [
|
||||
{
|
||||
key: "rent",
|
||||
title: "Rent",
|
||||
subtitle: "Historic or current rent information.",
|
||||
icon: PoundSterling,
|
||||
color: "text-brandbrown",
|
||||
},
|
||||
{
|
||||
key: "valuation",
|
||||
title: "Valuation",
|
||||
subtitle: "Property valuation data.",
|
||||
icon: Home,
|
||||
color: "text-midblue",
|
||||
},
|
||||
];
|
||||
|
||||
export function PlaceholderMetricCards({
|
||||
items,
|
||||
}: {
|
||||
items: PlaceholderCard[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{items.map((c) => {
|
||||
const Icon = c.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={c.key}
|
||||
className="relative h-full flex flex-col border border-gray-100 bg-gradient-to-br from-white to-gray-50 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300"
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-2 pb-1">
|
||||
<div className="p-1.5 rounded-md bg-gray-100">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-1.5 rounded-md bg-gray-50"
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${c.color}`} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<CardTitle className="text-md font-medium text-gray-600 mb-2">
|
||||
{c.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col pb-2 mb-2">
|
||||
<div className="text-lg font-semibold text-gray-400 italic">
|
||||
Data not provided
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-0 pb-4">
|
||||
<p className="text-xs text-gray-500">{c.subtitle}</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function SectionDivider({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-semibold text-brandblue tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{/* Animated gradient line */}
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
whileInView={{ width: "100%" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="h-1 mt-2 rounded-full bg-gradient-to-r from-brandblue via-midblue to-brandlightblue"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { scenario } from "@/app/db/schema/recommendations";
|
||||
|
||||
import type {
|
||||
AverageMetrics,
|
||||
AgeBandCount,
|
||||
EpcBandCount,
|
||||
EstimatedCounts,
|
||||
PropertyTypeCount,
|
||||
BaselineMetrics,
|
||||
TotalMetrics,
|
||||
} from "./types";
|
||||
|
||||
export async function getPortfolioCounts(portfolioId: number): Promise<number> {
|
||||
const result = await db.execute<{ total: number }>(sql`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0].total;
|
||||
}
|
||||
|
||||
export async function getAverages(
|
||||
portfolioId: number
|
||||
): Promise<AverageMetrics> {
|
||||
const result = await db.execute<AverageMetrics>(sql`
|
||||
SELECT
|
||||
AVG(p.current_sap_points)::float AS avg_sap,
|
||||
AVG(e.co2_emissions)::float AS avg_carbon,
|
||||
AVG(
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge
|
||||
)::float AS avg_bills,
|
||||
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
|
||||
const result = await db.execute<TotalMetrics>(sql`
|
||||
SELECT
|
||||
SUM(e.co2_emissions)::float AS total_carbon,
|
||||
SUM(
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge
|
||||
)::float AS total_bills
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getCountByAgeBand(
|
||||
portfolioId: number
|
||||
): Promise<AgeBandCount[]> {
|
||||
const result = await db.execute<AgeBandCount>(sql`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN year_built ~ '^[0-9]+$' THEN
|
||||
CASE
|
||||
WHEN CAST(year_built AS int) < 1900 THEN '<1900'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1900 AND 1929 THEN '1900–1929'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '1930–1949'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '1950–1975'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '1976–1999'
|
||||
ELSE '2000+'
|
||||
END
|
||||
ELSE 'Unknown'
|
||||
END AS age_band,
|
||||
COUNT(*)::int AS count
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId}
|
||||
GROUP BY age_band
|
||||
ORDER BY age_band;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getCountByEpcBand(
|
||||
portfolioId: number
|
||||
): Promise<EpcBandCount[]> {
|
||||
const result = await db.execute<EpcBandCount>(sql`
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
|
||||
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual,
|
||||
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY epc
|
||||
) q
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.epc = 'A' THEN 1
|
||||
WHEN q.epc = 'B' THEN 2
|
||||
WHEN q.epc = 'C' THEN 3
|
||||
WHEN q.epc = 'D' THEN 4
|
||||
WHEN q.epc = 'E' THEN 5
|
||||
WHEN q.epc = 'F' THEN 6
|
||||
WHEN q.epc = 'G' THEN 7
|
||||
ELSE 8 -- 'Unknown'
|
||||
END;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getEstimatedCounts(
|
||||
portfolioId: number
|
||||
): Promise<EstimatedCounts> {
|
||||
const result = await db.execute<EstimatedCounts>(sql`
|
||||
SELECT
|
||||
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated,
|
||||
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual
|
||||
FROM property_details_epc e
|
||||
WHERE e.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getCountByPropertyType(
|
||||
portfolioId: number
|
||||
): Promise<PropertyTypeCount[]> {
|
||||
const result = await db.execute<PropertyTypeCount>(sql`
|
||||
SELECT property_type AS type, COUNT(*)::int AS count
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId}
|
||||
GROUP BY property_type
|
||||
ORDER BY count DESC;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function loadBaselineMetrics(
|
||||
portfolioId: number
|
||||
): Promise<BaselineMetrics> {
|
||||
const [total, averages, totals, ageBands, epcBands, estimatedCounts] =
|
||||
await Promise.all([
|
||||
getPortfolioCounts(portfolioId),
|
||||
getAverages(portfolioId),
|
||||
getTotals(portfolioId),
|
||||
getCountByAgeBand(portfolioId),
|
||||
getCountByEpcBand(portfolioId),
|
||||
getEstimatedCounts(portfolioId),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
averages,
|
||||
totals,
|
||||
ageBands,
|
||||
epcBands,
|
||||
estimatedCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getScenarios(portfolioId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(scenario)
|
||||
.where(eq(scenario.portfolioId, BigInt(portfolioId)));
|
||||
|
||||
// Normalise response (only return what we need right now)
|
||||
return rows.map((s) => ({
|
||||
id: Number(s.id),
|
||||
name: s.name ?? `Scenario ${s.id}`,
|
||||
// we will compute the performance metrics in another function
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import type { FC } from "react";
|
||||
|
||||
export interface ScenarioOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ScenarioSelectorProps {
|
||||
scenarios: ScenarioOption[];
|
||||
selected: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export const ScenarioSelector: FC<ScenarioSelectorProps> = ({
|
||||
scenarios,
|
||||
selected,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600">Scenario:</span>
|
||||
|
||||
<Select
|
||||
value={selected ? String(selected) : "none"}
|
||||
onValueChange={(val) => {
|
||||
if (val === "none") onChange(null);
|
||||
else onChange(Number(val));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-56 bg-white border-gray-200 shadow-sm">
|
||||
<SelectValue placeholder="Select scenario" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scenario (baseline only)</SelectItem>
|
||||
|
||||
{scenarios.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
81
src/app/portfolio/[slug]/(portfolio)/reporting/types.ts
Normal file
81
src/app/portfolio/[slug]/(portfolio)/reporting/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue