fixed filter skeleton ui loading states

This commit is contained in:
Khalim Conn-Kowlessar 2026-02-07 21:06:21 +00:00
parent c7e7f6c50e
commit ee1e6d31ab
2 changed files with 125 additions and 91 deletions

View file

@ -9,23 +9,29 @@ import {
} 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 { formatNumber, sapToEpc } from "@/app/utils";
import type {
AverageMetrics,
EstimatedCounts,
TotalMetrics,
ScenarioOverlayMetrics,
MetricKey,
} from "./types";
import type { MetricKey } from "./types";
import { sapToEpc } from "@/app/utils";
const cardStyles = {
/* ───────────────────────────────────────────── */
/* Style maps */
/* ───────────────────────────────────────────── */
const cardStyles: Record<
MetricKey,
{ icon: React.ComponentType<any>; color: string }
> = {
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",
@ -38,24 +44,38 @@ const epcColors: Record<string, string> = {
Unknown: "text-gray-400",
};
/* ───────────────────────────────────────────── */
/* Helpers */
/* ───────────────────────────────────────────── */
function hasOverlay(
overlay: ScenarioOverlayMetrics | undefined
overlay: ScenarioOverlayMetrics | undefined,
): overlay is ScenarioOverlayMetrics {
return overlay !== undefined;
}
function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded bg-gray-200 ${className}`} />;
}
/* ───────────────────────────────────────────── */
/* Component */
/* ───────────────────────────────────────────── */
export function DashboardSummaryCards({
total,
totals,
averages,
estimatedCounts,
scenarioOverlay,
loading = false,
}: {
total: number;
totals: TotalMetrics;
averages: AverageMetrics;
estimatedCounts: EstimatedCounts;
scenarioOverlay?: ScenarioOverlayMetrics | null;
loading?: boolean;
}) {
const missingEpcCount = estimatedCounts.estimated;
const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0;
@ -66,10 +86,7 @@ export function DashboardSummaryCards({
const hasScenario = hasOverlay(overlay);
function deltaLabel(baseline: number, scenario: number) {
const b = Number(baseline);
const s = Number(scenario);
const diff = s - b;
const diff = scenario - baseline;
if (!isFinite(diff) || diff === 0) return null;
const sign = diff > 0 ? "▲" : "▼";
@ -87,10 +104,6 @@ export function DashboardSummaryCards({
key: "totalHomes",
title: "Number of Homes",
baseline: total,
scenario: null,
baselineTotal: undefined,
scenarioTotal: undefined,
units: "",
subtitle: "Total properties in this portfolio.",
},
{
@ -100,8 +113,6 @@ export function DashboardSummaryCards({
scenario:
overlay?.avgSap &&
`${sapToEpc(overlay.avgSap.scenario)} (${overlay.avgSap.scenario} pts)`,
baselineTotal: undefined,
scenarioTotal: undefined,
subtitle: "Current SAP rating across all properties.",
isEpc: true,
},
@ -144,92 +155,110 @@ export function DashboardSummaryCards({
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"
className="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"
>
{/* Header */}
<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>
{loading ? (
<>
<Skeleton className="h-5 w-5 rounded-full" />
<Skeleton className="h-4 w-32" />
</>
) : (
<>
<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>
{/* Content */}
<CardContent className="flex flex-1 flex-col gap-2">
{/* BASELINE + SCENARIO ROW */}
<div
className={`flex ${
hasScenario ? "justify-between" : "justify-start"
} items-start`}
>
{/* BASELINE COLUMN */}
{/* Baseline */}
<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 print-text-solid"
}
>
{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>
{loading ? (
<Skeleton className="h-8 w-28 mt-1" />
) : (
<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>
{c.units && (
<span className="text-sm text-gray-500">{c.units}</span>
)}
</div>
)}
{c.baselineTotal !== undefined &&
(loading ? (
<Skeleton className="h-4 w-36 mt-1" />
) : (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.baselineTotal)}`
: `${formatNumber(c.baselineTotal)} tCO₂e`}
</span>
))}
</div>
{/* SCENARIO COLUMN */}
{/* Scenario */}
{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>
{loading ? (
<Skeleton className="h-7 w-24 mt-1 ml-auto" />
) : (
<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>
)}
{c.scenarioTotal !== undefined &&
(loading ? (
<Skeleton className="h-4 w-36 mt-1 ml-auto" />
) : (
<span className="text-md text-gray-600">
Total:{" "}
{c.key === "avgBills"
? `£${formatNumber(c.scenarioTotal)}`
: `${formatNumber(c.scenarioTotal)} tCO₂e`}
</span>
))}
</div>
)}
</div>
@ -246,7 +275,11 @@ export function DashboardSummaryCards({
</CardContent>
<CardFooter>
<p className="text-xs text-gray-500">{c.subtitle}</p>
{loading ? (
<Skeleton className="h-3 w-3/4" />
) : (
<p className="text-xs text-gray-500">{c.subtitle}</p>
)}
</CardFooter>
</Card>
);

View file

@ -127,8 +127,6 @@ export function ReportingClientArea({
keepPreviousData: true,
});
const scenarioLoading = isLoading && !!selectedScenarioId;
// ----------------------------------------
// Build overlay for Dashboard Summary cards
// ----------------------------------------
@ -198,6 +196,8 @@ export function ReportingClientArea({
// Baseline stays baseline
const activeMetrics = baseline;
const scenarioBusy = !!selectedScenarioId && (isLoading || isFetching);
return (
<>
<div className="flex items-center justify-between gap-4">
@ -215,22 +215,22 @@ export function ReportingClientArea({
{/* Show measures */}
<button
onClick={() => setMeasuresOpen(true)}
disabled={scenarioLoading}
disabled={true}
className={`
rounded-md px-3 py-2 text-sm font-medium transition
${
scenarioLoading
scenarioBusy
? "bg-gray-200 text-gray-400 cursor-not-allowed"
: "bg-brandblue text-white hover:bg-hoverblue"
}
`}
>
{scenarioLoading ? "Loading…" : "Show measures"}
{scenarioBusy ? "Loading…" : "Show measures"}
</button>
<ReportingFunctionalityButtons
hideNonCompliant={appliedHideNonCompliant}
disabled={scenarioLoading}
disabled={scenarioBusy}
onApply={async (value) => {
setAppliedHideNonCompliant(value);
}}
@ -244,11 +244,11 @@ export function ReportingClientArea({
"_blank",
);
}}
disabled={scenarioLoading}
disabled={scenarioBusy}
className={`
rounded-md border px-3 py-2 text-sm font-medium transition
${
scenarioLoading
scenarioBusy
? "border-gray-200 text-gray-400 cursor-not-allowed"
: "hover:bg-gray-50"
}
@ -279,7 +279,7 @@ export function ReportingClientArea({
<ScenarioFinancialDrawer
open={drawerOpen}
metrics={scenarioSpecific}
loading={isFetching && !!scenarioData}
loading={scenarioBusy}
/>
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
@ -289,6 +289,7 @@ export function ReportingClientArea({
averages={activeMetrics.averages}
estimatedCounts={activeMetrics.estimatedCounts}
scenarioOverlay={scenarioOverlay}
loading={scenarioBusy}
/>
<BreakdownChart