mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
6b71e73e25
commit
a1bfde40f8
1 changed files with 204 additions and 82 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue