feat(reporting): restyle the Investment ledger

The scenario Investment ledger was a flat monochrome list. Give it a
visual hierarchy while keeping the reporting design vocabulary (quiet
navy-on-white, semantic green for benefits):
- lead with a Net cost hero block — the headline figure a board commits
  to — with gross/home and the gross · funding breakdown beneath it;
- turn the cost lines into a colour-keyed composition bar (navy → gold →
  grey, echoing the data-quality evidence bar) with matching dots;
- give annual benefits green icon-chip rows (they're recurring savings)
  and value-for-money its own iconography;
- add section and header icons throughout.

Purely presentational — same LedgerView data, no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 15:21:22 +01:00
parent 6b71e73e25
commit a1bfde40f8

View file

@ -6,7 +6,19 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import { Info } from "lucide-react";
import {
Info,
Wallet,
Coins,
Landmark,
Sprout,
Banknote,
Leaf,
Scale,
Gauge,
Cloud,
type LucideIcon,
} from "lucide-react";
import { moneyFull } from "./primitives";
import { carsOffTheRoad, PROJECT_DELIVERY_RATE } from "@/lib/reporting/model";
@ -25,108 +37,218 @@ export interface LedgerView {
costPerCarbon: number;
}
function Row({
label,
value,
tip,
emphatic = false,
credit = false,
note,
}: {
label: string;
value: string;
tip?: string;
emphatic?: boolean;
credit?: boolean;
note?: string;
}) {
const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100);
// The cost-composition palette echoes the data-quality evidence bar: quiet
// navy → gold → grey, so the reporting surfaces read as one system.
const COST_COLORS = {
construction: "#14163d",
delivery: "#a07c42",
contingency: "#c5cad8",
} as const;
function InfoDot({ label, tip }: { label: string; tip: string }) {
return (
<div
className={`flex items-baseline justify-between gap-3 px-5 py-1.5 text-[0.84rem] ${
emphatic ? "mt-1 border-t border-gray-100 pt-2.5 pb-3" : ""
}`}
>
<span
className={`inline-flex items-center gap-1.5 ${emphatic ? "font-semibold text-gray-900" : "text-gray-500"}`}
>
{label}
{tip && (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About: ${label}`}
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-gray-300 text-[0.62rem] text-gray-600 hover:text-gray-900"
>
<Info className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-xs leading-relaxed">
{tip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
<span
className={`whitespace-nowrap tabular-nums ${emphatic ? "font-semibold text-brandblue" : credit ? "font-semibold text-[#0c6b4a]" : "font-semibold text-brandblue"}`}
>
{value}
{note && <span className="font-normal text-gray-600"> · {note}</span>}
</span>
</div>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About: ${label}`}
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-gray-300 text-gray-600 hover:text-gray-900"
>
<Info className="h-2.5 w-2.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-xs leading-relaxed">
{tip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function GroupLabel({ children }: { children: React.ReactNode }) {
function GroupLabel({
icon: Icon,
children,
}: {
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<div className="px-5 pb-1 pt-3 text-[0.68rem] font-bold uppercase tracking-wide text-[#a07c42]">
<div className="flex items-center gap-1.5 px-5 pb-1.5 pt-4 text-[0.68rem] font-bold uppercase tracking-wide text-[#a07c42]">
<Icon className="h-3.5 w-3.5" />
{children}
</div>
);
}
const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100);
/** A row led by an icon chip. `tone` tints benefits green, keeps the rest quiet. */
function IconRow({
icon: Icon,
label,
value,
note,
tone = "neutral",
}: {
icon: LucideIcon;
label: string;
value: string;
note?: string;
tone?: "neutral" | "benefit";
}) {
const chip =
tone === "benefit"
? "bg-[#e9f4ef] text-[#0c6b4a]"
: "bg-gray-50 text-gray-500";
const valueColor = tone === "benefit" ? "text-[#0c6b4a]" : "text-brandblue";
return (
<div className="flex items-center justify-between gap-3 py-1 text-[0.84rem]">
<span className="inline-flex items-center gap-2 text-gray-600">
<span
className={`inline-flex h-6 w-6 items-center justify-center rounded-md ${chip}`}
>
<Icon className="h-3.5 w-3.5" />
</span>
{label}
</span>
<span
className={`whitespace-nowrap tabular-nums font-semibold ${valueColor}`}
>
{value}
{note && <span className="font-normal text-gray-500"> · {note}</span>}
</span>
</div>
);
}
export function InvestmentLedger({ v }: { v: LedgerView }) {
const costParts = [
{
label: "Construction works",
value: v.constructionCost,
color: COST_COLORS.construction,
},
{
label: "Project delivery",
value: v.projectDelivery,
color: COST_COLORS.delivery,
},
{
label: "Contingency",
value: v.contingency,
color: COST_COLORS.contingency,
},
];
const grossForBar = v.grossCost || 1;
return (
<div className="flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white">
<header className="px-5 pt-4">
<header className="flex items-center gap-2 px-5 pt-4">
<Wallet className="h-4 w-4 text-brandblue" />
<h3 className="text-[0.88rem] font-semibold text-brandblue">
Investment ledger
</h3>
</header>
<GroupLabel>Costs</GroupLabel>
<Row label="Construction works" value={moneyFull(v.constructionCost)} />
<Row label="Project delivery" value={moneyFull(v.projectDelivery)} />
<Row label="Contingency" value={moneyFull(v.contingency)} />
<Row label="Funding secured" value={`${moneyFull(v.funding)}`} credit />
<Row
label="Net cost"
value={moneyFull(v.netCost)}
note={`${moneyFull(v.grossPerHome)} gross/home`}
emphatic
tip={`Gross cost = construction works + project delivery + contingency. Funding is deducted to give net. Excludes VAT and internal staff time. Project delivery is currently estimated at ${deliveryPct}% of construction.`}
/>
{/* Net cost — the headline figure a board commits to. */}
<div className="mx-5 mt-3 rounded-lg border border-gray-100 bg-[#f6f7fb] px-4 py-3">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-1 text-[0.68rem] font-bold uppercase tracking-wide text-gray-500">
Net cost
<InfoDot
label="Net cost"
tip={`Gross cost = construction works + project delivery + contingency. Funding is deducted to give net. Excludes VAT and internal staff time. Project delivery is currently estimated at ${deliveryPct}% of construction.`}
/>
</span>
<span className="text-[0.72rem] font-medium text-gray-500">
{moneyFull(v.grossPerHome)}{" "}
<span className="text-gray-400">gross/home</span>
</span>
</div>
<div className="mt-1 text-[1.6rem] font-bold leading-none tabular-nums text-brandblue">
{moneyFull(v.netCost)}
</div>
<div className="mt-1.5 text-[0.72rem] text-gray-500">
{moneyFull(v.grossCost)} gross · {moneyFull(v.funding)} funding
secured
</div>
</div>
<GroupLabel>Annual benefits</GroupLabel>
<Row
label="Bill savings"
value={moneyFull(v.billSavings)}
note={`${moneyFull(v.billSavingsPerHome)}/home`}
/>
<Row
label="Carbon saved"
value={`${Math.round(v.carbonSaved)} t`}
note={`${carsOffTheRoad(v.carbonSaved)} cars`}
/>
{/* Cost breakdown — a proportion bar + rows keyed to it by colour. */}
<GroupLabel icon={Coins}>Cost breakdown</GroupLabel>
<div className="px-5">
<div className="mb-3 flex h-2 overflow-hidden rounded-full">
{costParts.map((p) => (
<span
key={p.label}
style={{
width: `${(p.value / grossForBar) * 100}%`,
background: p.color,
}}
/>
))}
</div>
{costParts.map((p) => (
<div
key={p.label}
className="flex items-center justify-between gap-3 py-1 text-[0.84rem]"
>
<span className="inline-flex items-center gap-2 text-gray-500">
<span
className="h-2 w-2 rounded-full"
style={{ background: p.color }}
/>
{p.label}
</span>
<span className="tabular-nums font-semibold text-brandblue">
{moneyFull(p.value)}
</span>
</div>
))}
<div className="mt-1 flex items-center justify-between gap-3 border-t border-gray-100 py-1 pt-2 text-[0.84rem]">
<span className="inline-flex items-center gap-2 text-gray-600">
<Landmark className="h-3.5 w-3.5 text-[#0c6b4a]" />
Funding secured
</span>
<span className="tabular-nums font-semibold text-[#0c6b4a]">
{moneyFull(v.funding)}
</span>
</div>
</div>
<GroupLabel>Value for money</GroupLabel>
<Row label="Cost per SAP point" value={moneyFull(v.costPerSap)} />
<div className="pb-3">
<Row label="Cost per tCO₂/yr" value={moneyFull(v.costPerCarbon)} />
{/* Annual benefits — recurring, so tinted green like the savings they are. */}
<GroupLabel icon={Sprout}>Annual benefits</GroupLabel>
<div className="px-5">
<IconRow
icon={Banknote}
label="Bill savings"
value={moneyFull(v.billSavings)}
note={`${moneyFull(v.billSavingsPerHome)}/home`}
tone="benefit"
/>
<IconRow
icon={Leaf}
label="Carbon saved"
value={`${Math.round(v.carbonSaved)} t`}
note={`${carsOffTheRoad(v.carbonSaved)} cars`}
tone="benefit"
/>
</div>
{/* Value for money */}
<GroupLabel icon={Scale}>Value for money</GroupLabel>
<div className="px-5 pb-4">
<IconRow
icon={Gauge}
label="Cost per SAP point"
value={moneyFull(v.costPerSap)}
/>
<IconRow
icon={Cloud}
label="Cost per tCO₂/yr"
value={moneyFull(v.costPerCarbon)}
/>
</div>
</div>
);