mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
adjusted loading user experience to open drawer -> show loading skeletons -> show metrics
This commit is contained in:
parent
dce30edf00
commit
c7e7f6c50e
2 changed files with 168 additions and 70 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ScenarioSelectorWrapper } from "./scenarioSelectorWrapper";
|
||||
import { DashboardSummaryCards } from "./DashboardSummaryCards";
|
||||
|
|
@ -124,6 +124,7 @@ export function ReportingClientArea({
|
|||
scenarioId: selectedScenarioId!,
|
||||
}),
|
||||
enabled: measuresOpen && !!selectedScenarioId,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const scenarioLoading = isLoading && !!selectedScenarioId;
|
||||
|
|
@ -160,37 +161,40 @@ export function ReportingClientArea({
|
|||
// Scenario specific metrics that appear in the drawer (from API) and cannot be overlayed on baseline
|
||||
// ----------------------------------------
|
||||
|
||||
const scenarioSpecific = scenarioData
|
||||
? {
|
||||
constructionCost: scenarioData.construction_cost,
|
||||
pcCost: scenarioData.pc_cost,
|
||||
contingency: scenarioData.contingency,
|
||||
funding: scenarioData.total_funding,
|
||||
costPerSap:
|
||||
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
scenarioData.total_sap_uplift
|
||||
: 0,
|
||||
costPerCo2:
|
||||
scenarioData.construction_cost > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
|
||||
: 0,
|
||||
netCost: scenarioData.net_cost,
|
||||
grossPerUnit: scenarioData.gross_per_unit,
|
||||
nUnits: scenarioData.n_units_upgraded,
|
||||
totalCarbonSaved:
|
||||
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
|
||||
totalBillsSaved:
|
||||
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
|
||||
averageCaribonSaved:
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
|
||||
scenarioData.n_units_upgraded,
|
||||
averageBillsSaved:
|
||||
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
|
||||
scenarioData.n_units_upgraded,
|
||||
}
|
||||
: null;
|
||||
const scenarioSpecific = useMemo(() => {
|
||||
if (!scenarioData) return null;
|
||||
|
||||
return {
|
||||
constructionCost: scenarioData.construction_cost,
|
||||
pcCost: scenarioData.pc_cost,
|
||||
contingency: scenarioData.contingency,
|
||||
funding: scenarioData.total_funding,
|
||||
costPerSap:
|
||||
scenarioData.total_sap_uplift && scenarioData.total_sap_uplift > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
scenarioData.total_sap_uplift
|
||||
: 0,
|
||||
costPerCo2:
|
||||
scenarioData.construction_cost > 0
|
||||
? (scenarioData.construction_cost + scenarioData.pc_cost) /
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon)
|
||||
: 0,
|
||||
netCost: scenarioData.net_cost,
|
||||
grossPerUnit: scenarioData.gross_per_unit,
|
||||
nUnits: scenarioData.n_units_upgraded,
|
||||
totalCarbonSaved:
|
||||
(baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon,
|
||||
totalBillsSaved:
|
||||
(baseline.totals.total_bills ?? 0) - scenarioData.total_bills,
|
||||
averageCaribonSaved:
|
||||
((baseline.totals.total_carbon ?? 0) - scenarioData.total_carbon) /
|
||||
scenarioData.n_units_upgraded,
|
||||
averageBillsSaved:
|
||||
((baseline.totals.total_bills ?? 0) - scenarioData.total_bills) /
|
||||
scenarioData.n_units_upgraded,
|
||||
};
|
||||
}, [scenarioData, baseline]);
|
||||
|
||||
// Baseline stays baseline
|
||||
const activeMetrics = baseline;
|
||||
|
||||
|
|
@ -272,7 +276,11 @@ export function ReportingClientArea({
|
|||
subtitle="High-level insights on performance, energy, and EPC quality."
|
||||
/>
|
||||
|
||||
<ScenarioFinancialDrawer open={drawerOpen} metrics={scenarioSpecific} />
|
||||
<ScenarioFinancialDrawer
|
||||
open={drawerOpen}
|
||||
metrics={scenarioSpecific}
|
||||
loading={isFetching && !!scenarioData}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[60%_40%] gap-6 p-2">
|
||||
<DashboardSummaryCards
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { Gauge } from "lucide-react";
|
|||
interface ScenarioFinancialDrawerProps {
|
||||
open: boolean;
|
||||
metrics: any | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
|
@ -56,7 +57,7 @@ function GradientCard({
|
|||
className={clsx(
|
||||
"relative rounded-lg p-[2px] gradient-card",
|
||||
gradient,
|
||||
`gradient-${variant}`
|
||||
`gradient-${variant}`,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-[7px] bg-white h-full">{children}</div>
|
||||
|
|
@ -75,6 +76,7 @@ function Metric({
|
|||
color,
|
||||
gradient,
|
||||
variant = "green",
|
||||
loading = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
|
|
@ -82,15 +84,38 @@ function Metric({
|
|||
color: string;
|
||||
gradient: string;
|
||||
variant?: "green" | "blue" | "purple";
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading || !value) {
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="p-4 h-full animate-pulse">
|
||||
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="flex flex-col items-center justify-center p-4 h-full text-center">
|
||||
<Icon className={clsx("h-6 w-6 mb-2", color)} />
|
||||
<span className="text-3xl font-semibold text-gray-900">{value}</span>
|
||||
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="w-full animate-pulse space-y-3">
|
||||
<div className="h-6 w-6 mx-auto rounded bg-gray-200" />
|
||||
<div className="h-8 w-2/3 mx-auto rounded bg-gray-200" />
|
||||
<div className="h-3 w-1/2 mx-auto rounded bg-gray-200" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Icon className={clsx("h-6 w-6 mb-2", color)} />
|
||||
<span className="text-3xl font-semibold text-gray-900">
|
||||
{value}
|
||||
</span>
|
||||
<span className="mt-1 text-xs uppercase tracking-wide font-semibold text-gray-500">
|
||||
{label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
|
|
@ -108,6 +133,7 @@ function PairedMetric({
|
|||
gradient,
|
||||
iconClassName = "text-gray-700",
|
||||
variant = "green",
|
||||
loading = false,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
|
|
@ -116,30 +142,62 @@ function PairedMetric({
|
|||
gradient: string;
|
||||
iconClassName?: string;
|
||||
variant?: "green" | "blue" | "purple";
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading || !primary.value || !secondary.value) {
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="p-4 h-full animate-pulse">
|
||||
<div className="h-4 w-1/2 bg-gray-200 rounded mb-3" />
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientCard gradient={gradient} variant={variant}>
|
||||
<div className="p-4 h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={clsx("h-5 w-5", iconClassName)} />
|
||||
<span className="text-sm font-semibold text-gray-900">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{primary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{primary.value}
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-1/3 rounded bg-gray-200" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-gray-200" />
|
||||
<div className="h-6 w-full rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-gray-200" />
|
||||
<div className="h-6 w-full rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={clsx("h-5 w-5", iconClassName)} />
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{secondary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{secondary.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{primary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{primary.value}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">{secondary.label}</p>
|
||||
<p className="text-xl font-semibold text-gray-900">
|
||||
{secondary.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GradientCard>
|
||||
);
|
||||
|
|
@ -172,7 +230,7 @@ function Section({
|
|||
<div
|
||||
className={clsx(
|
||||
"rounded-lg p-2 bg-white shadow-sm border",
|
||||
accentColor
|
||||
accentColor,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
|
|
@ -191,6 +249,22 @@ function Section({
|
|||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Loading Skeleton for dashboard cards */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
||||
function LoadingOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 rounded-lg bg-white/70 backdrop-blur-sm">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-6 animate-pulse">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-lg bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────── */
|
||||
/* Main Drawer */
|
||||
/* ───────────────────────────────────────────── */
|
||||
|
|
@ -198,10 +272,11 @@ function Section({
|
|||
export function ScenarioFinancialDrawer({
|
||||
open,
|
||||
metrics,
|
||||
loading = false,
|
||||
}: ScenarioFinancialDrawerProps) {
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open && metrics && (
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
|
|
@ -228,14 +303,17 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-green-700"
|
||||
primary={{
|
||||
label: "Total carbon saved (t/yr)",
|
||||
value: formatNumber(metrics.totalCarbonSaved),
|
||||
value: metrics ? formatNumber(metrics.totalCarbonSaved) : "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "Average per unit (t/yr)",
|
||||
value: formatNumber(metrics.averageCaribonSaved),
|
||||
value: metrics
|
||||
? formatNumber(metrics.averageCaribonSaved)
|
||||
: "",
|
||||
}}
|
||||
gradient={gradients.green}
|
||||
variant="green"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<PairedMetric
|
||||
|
|
@ -244,23 +322,29 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-green-700"
|
||||
primary={{
|
||||
label: "Total bill savings (£/yr)",
|
||||
value: `£${formatNumber(metrics.totalBillsSaved)}`,
|
||||
value: metrics
|
||||
? `£${formatNumber(metrics.totalBillsSaved)}`
|
||||
: "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "Average per unit (£/yr)",
|
||||
value: `£${formatNumber(metrics.averageBillsSaved)}`,
|
||||
value: metrics
|
||||
? `£${formatNumber(metrics.averageBillsSaved)}`
|
||||
: "",
|
||||
}}
|
||||
gradient={gradients.green}
|
||||
variant="green"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Homes upgraded"
|
||||
value={metrics.nUnits}
|
||||
value={metrics ? metrics.nUnits : ""}
|
||||
icon={HomeIcon}
|
||||
color="text-green-700"
|
||||
gradient={gradients.green}
|
||||
variant="green"
|
||||
loading={loading}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
|
@ -278,32 +362,37 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-blue-600"
|
||||
primary={{
|
||||
label: "Construction works",
|
||||
value: `£${formatNumber(metrics.constructionCost)}`,
|
||||
value: metrics
|
||||
? `£${formatNumber(metrics.constructionCost)}`
|
||||
: "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "Project delivery",
|
||||
value: `£${formatNumber(metrics.pcCost)}`,
|
||||
value: metrics ? `£${formatNumber(metrics.pcCost)}` : "",
|
||||
}}
|
||||
gradient={gradients.blue}
|
||||
variant="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Gross cost per unit"
|
||||
value={`£${formatNumber(metrics.grossPerUnit)}`}
|
||||
value={metrics ? `£${formatNumber(metrics.grossPerUnit)}` : ""}
|
||||
icon={HomeIcon}
|
||||
color="text-blue-600"
|
||||
gradient={gradients.blue}
|
||||
variant="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<Metric
|
||||
label="Contingency"
|
||||
value={`£${formatNumber(metrics.contingency)}`}
|
||||
value={metrics ? `£${formatNumber(metrics.contingency)}` : ""}
|
||||
icon={Gauge}
|
||||
color="text-blue-600"
|
||||
gradient={gradients.blue}
|
||||
variant="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
|
@ -321,14 +410,15 @@ export function ScenarioFinancialDrawer({
|
|||
iconClassName="text-purple-700"
|
||||
primary={{
|
||||
label: "£ per SAP point",
|
||||
value: `£${formatNumber(metrics.costPerSap)}`,
|
||||
value: metrics ? `£${formatNumber(metrics.costPerSap)}` : "",
|
||||
}}
|
||||
secondary={{
|
||||
label: "£ per tonne CO₂",
|
||||
value: `£${formatNumber(metrics.costPerCo2)}`,
|
||||
value: metrics ? `£${formatNumber(metrics.costPerCo2)}` : "",
|
||||
}}
|
||||
gradient={gradients.purple}
|
||||
variant="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue