mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat/polish(reporting): compare-column picker + consistency fixes
- Compare view: columns are no longer frozen to the first 4 scenarios. Add/remove columns (up to 4) via an 'Add scenario' popover and a per-column remove button — the compare page's core action now works. (Critique P1.) - Money formatting: deleted the local moneyShort, routed the allocation bar through the shared moneyCompact so '£2.48m' and the ledger's full figures come from one rule. (Critique P2.) - Drill shelf scrolls into view on open (in the handler, not an effect; reduced-motion aware) so a click below the fold isn't inert. tsc clean, 446 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
178b63e7c5
commit
c638e59f20
4 changed files with 106 additions and 21 deletions
|
|
@ -97,6 +97,19 @@ export function ReportingClientArea({
|
|||
function openDrill(t: DrillTarget) {
|
||||
setDrill(t);
|
||||
setDrillPage(1);
|
||||
// Bring the shelf into view so a click below the fold doesn't look inert.
|
||||
// Done in the handler (not an effect) per project convention.
|
||||
if (typeof window !== "undefined") {
|
||||
const reduce = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
requestAnimationFrame(() =>
|
||||
document.getElementById("reporting-drill-shelf")?.scrollIntoView({
|
||||
behavior: reduce ? "auto" : "smooth",
|
||||
block: "nearest",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── KPI band ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/app/shadcn_components/ui/popover";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import { pickBestIndex, countBelowBand } from "@/lib/reporting/model";
|
||||
import { EpcChip, moneyFull } from "../components/primitives";
|
||||
|
|
@ -138,9 +144,24 @@ export function CompareClientArea({
|
|||
scenarios: ScenarioMeta[];
|
||||
baseline: Baseline;
|
||||
}) {
|
||||
// Up to four columns stay readable at desk widths.
|
||||
const [selected] = useState(() => scenarios.slice(0, 4).map((s) => s.id));
|
||||
const columns = scenarios.filter((s) => selected.includes(s.id));
|
||||
// Up to four columns stay readable at desk widths; the user picks which.
|
||||
const MAX_COLUMNS = 4;
|
||||
const [selected, setSelected] = useState(() =>
|
||||
scenarios.slice(0, MAX_COLUMNS).map((s) => s.id),
|
||||
);
|
||||
// Column order follows selection order, not the scenario list order.
|
||||
const columns = selected
|
||||
.map((id) => scenarios.find((s) => s.id === id))
|
||||
.filter((s): s is ScenarioMeta => Boolean(s));
|
||||
const available = scenarios.filter((s) => !selected.includes(s.id));
|
||||
const canAdd = selected.length < MAX_COLUMNS && available.length > 0;
|
||||
|
||||
const addColumn = (id: number) =>
|
||||
setSelected((ids) =>
|
||||
ids.length < MAX_COLUMNS && !ids.includes(id) ? [...ids, id] : ids,
|
||||
);
|
||||
const removeColumn = (id: number) =>
|
||||
setSelected((ids) => (ids.length > 1 ? ids.filter((x) => x !== id) : ids));
|
||||
|
||||
const results = useQueries({
|
||||
queries: columns.map((s) => ({
|
||||
|
|
@ -171,12 +192,54 @@ export function CompareClientArea({
|
|||
Compare scenarios
|
||||
</h1>
|
||||
</div>
|
||||
<a
|
||||
href={`/portfolio/${portfolioId}/reporting`}
|
||||
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
|
||||
>
|
||||
← Back to reporting
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAdd}
|
||||
aria-haspopup="listbox"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium text-brandblue disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
>
|
||||
+ Add scenario
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div role="listbox" aria-label="Add a scenario to compare" className="max-h-80 overflow-y-auto">
|
||||
{available.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={false}
|
||||
onClick={() => addColumn(s.id)}
|
||||
className="flex w-full flex-col border-t border-gray-100 px-3.5 py-2.5 text-left first:border-t-0 hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-sm font-semibold text-brandblue">
|
||||
{s.name}
|
||||
</span>
|
||||
<span className="text-[0.7rem] text-gray-600">
|
||||
{s.goal === "Increasing EPC" && s.goalValue
|
||||
? `Target EPC ${s.goalValue}`
|
||||
: s.goal}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{available.length === 0 && (
|
||||
<p className="px-3.5 py-3 text-[0.78rem] text-gray-600">
|
||||
All scenarios are already shown.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<a
|
||||
href={`/portfolio/${portfolioId}/reporting`}
|
||||
className="inline-flex h-8 items-center rounded-lg border border-gray-200 bg-white px-3.5 text-sm font-medium"
|
||||
>
|
||||
← Back to reporting
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white">
|
||||
|
|
@ -194,8 +257,20 @@ export function CompareClientArea({
|
|||
</th>
|
||||
{columns.map((s) => (
|
||||
<th key={s.id} className="px-4 py-4 text-right align-bottom">
|
||||
<span className="text-[0.86rem] font-semibold text-brandblue">
|
||||
{s.name}
|
||||
<span className="flex items-start justify-end gap-1.5">
|
||||
<span className="text-[0.86rem] font-semibold text-brandblue">
|
||||
{s.name}
|
||||
</span>
|
||||
{columns.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeColumn(s.id)}
|
||||
aria-label={`Remove ${s.name} from comparison`}
|
||||
className="mt-0.5 text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[0.68rem] font-normal text-gray-600">
|
||||
{s.goal === "Increasing EPC" && s.goalValue
|
||||
|
|
|
|||
|
|
@ -93,7 +93,10 @@ export function DrillDownShelf({
|
|||
const tableHref = `/portfolio/${portfolioId}`;
|
||||
|
||||
return (
|
||||
<div className="mt-4 overflow-hidden rounded-lg border border-midblue bg-white">
|
||||
<div
|
||||
id="reporting-drill-shelf"
|
||||
className="mt-4 overflow-hidden rounded-lg border border-midblue bg-white"
|
||||
>
|
||||
<header className="flex items-center gap-3 border-b border-gray-100 px-5 py-3.5">
|
||||
<span className="flex items-center gap-2 text-[0.95rem] font-semibold text-brandblue">
|
||||
{target.chipBand && <EpcChip band={target.chipBand} size="md" />}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
type Measure,
|
||||
type MeasureGroup,
|
||||
} from "@/lib/reporting/measures";
|
||||
import { moneyFull } from "./primitives";
|
||||
import { moneyCompact, moneyFull } from "./primitives";
|
||||
import { loadScenarioMeasures } from "../actions";
|
||||
import type { DrillTarget } from "./DrillDownShelf";
|
||||
|
||||
|
|
@ -22,12 +22,6 @@ const SEGMENT_COLORS = [
|
|||
"#c5cad8",
|
||||
];
|
||||
|
||||
function moneyShort(n: number) {
|
||||
if (n >= 1_000_000) return `£${(n / 1_000_000).toFixed(2)}m`;
|
||||
if (n >= 1_000) return `£${Math.round(n / 1_000)}k`;
|
||||
return `£${Math.round(n)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Where the money goes" (Screen B lower). A single allocation bar by
|
||||
* measure category answers the shape of the spend; the full grouped table
|
||||
|
|
@ -114,7 +108,7 @@ export function MeasureAllocation({
|
|||
{groups.map((g, i) => (
|
||||
<span
|
||||
key={g.category}
|
||||
title={`${g.category} · ${moneyShort(g.costTotal)}`}
|
||||
title={`${g.category} · ${moneyCompact(g.costTotal)}`}
|
||||
style={{
|
||||
width: `${(g.costTotal / grandTotal) * 100}%`,
|
||||
background: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
|
||||
|
|
@ -136,7 +130,7 @@ export function MeasureAllocation({
|
|||
/>
|
||||
{g.category}{" "}
|
||||
<b className="font-semibold text-gray-900">
|
||||
{moneyShort(g.costTotal)}
|
||||
{moneyCompact(g.costTotal)}
|
||||
</b>{" "}
|
||||
· {g.homesTotal} homes
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue