mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
fixed filter skeleton ui loading states
This commit is contained in:
parent
c7e7f6c50e
commit
ee1e6d31ab
2 changed files with 125 additions and 91 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue