Sprint 2 UI and brand refresh

Workbench: ramp-adjusted headline (TCO, net run-rate, NPV, IRR, payback),
financial model panel with internal-only investment, year-by-year cash flow,
scenario panel (presets + custom, presented scenario, report comparison),
overlap heat map, shelfware and expansion panel, editable implementation
timeline (drag, resize, row editor, dependencies, reset), version history
with compare and restore, deal status, customer logo.

Deliverables: report, Word and new PowerPoint deck present the chosen
scenario with net figures only; investment and dossier entitlements never
itemized (covered by the leak suite). Exports and printing snapshot
automatically.

New pages: Portfolio dashboard, Settings (company name, logo, accent).
Knowledge base: duplicate queue with merge, confidence and sourcing,
TCO defaults, needs-review filter.

Brand: VMware by Broadcom palette per Brand Guidelines.pdf; Darkmode navy
dark theme; AA-safe accent ink; business units remapped to palette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 15:41:15 -04:00
parent 4496312845
commit 93007a1053
30 changed files with 2005 additions and 312 deletions

View File

@@ -2,7 +2,9 @@
import { useState } from "react";
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
import type { CandidateMetrics } from "@/lib/scoring";
import { type CandidateMetrics, TCO_FALLBACK } from "@/lib/scoring";
import { confidenceOf, findEntry } from "@/lib/kb";
import { InternalBadge } from "./Finance";
import { CAPABILITY_LABEL, SWITCH_LABEL, money, monthsLabel } from "@/lib/format";
import ProductPicker, { suggest } from "./ProductPicker";
import IncumbentPicker, { ConfidenceChip } from "./IncumbentPicker";
@@ -53,6 +55,7 @@ export default function CandidateRow({
migrationCost: e.migrationCost || c.migrationCost,
unitLabel: e.unitLabel || c.unitLabel,
risks: c.risks.trim() || e.risks,
tcoDefaults: { ...(e.tco ?? TCO_FALLBACK) },
});
setApplied(e);
}
@@ -61,6 +64,13 @@ export default function CandidateRow({
.filter((p) => !c.productIds.includes(p.id));
const mapped = c.productIds.map((id) => catalog.products.find((p) => p.id === id)).filter(Boolean);
const title = [c.incumbentVendor, c.incumbentProduct].filter(Boolean).join(" ") || "Untitled incumbent tool";
const entry = findEntry(kb, title);
const conf = entry ? confidenceOf(entry) : null;
const d = c.tcoDefaults ?? TCO_FALLBACK;
const setTco = (k: "infra" | "adminFte" | "other", v: number | null) =>
onChange({ ...c, tco: { infra: c.tco?.infra ?? null, adminFte: c.tco?.adminFte ?? null, other: c.tco?.other ?? null, [k]: v } });
const setInv = (k: "annual" | "services", v: number | null) =>
onChange({ ...c, investment: { annual: c.investment?.annual ?? null, services: c.investment?.services ?? null, [k]: v } });
return (
<div className={`card avoid-break overflow-hidden transition-opacity ${c.included ? "" : "opacity-55"}`}>
@@ -71,6 +81,10 @@ export default function CandidateRow({
<div className="flex items-center gap-2">
<span className="truncate text-[14px] font-semibold tracking-tight">{title}</span>
{!c.included && <span className="chip">Excluded</span>}
{c.alreadyEntitled && (
<span className="chip" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }}
title={`The customer already owns unused ${c.alreadyEntitled} licences — no new subscription for this line`}>Already entitled</span>
)}
</div>
<div className="truncate text-[12px] text-[var(--text-muted)]">
{mapped.length ? `→ ${mapped.map((p) => p!.name).join(", ")}` : "No replacement mapped yet"}
@@ -79,11 +93,11 @@ export default function CandidateRow({
<div className="hidden shrink-0 items-center gap-5 text-right sm:flex">
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend</div>
<div className="tabnum text-[13px] font-semibold">{money(c.annualSpend, true)}</div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">TCO</div>
<div className="tabnum text-[13px] font-semibold" title={`Licence ${money(m.licence)} · infra ${money(m.infra)} · admin ${money(m.admin)} · other ${money(m.other)}`}>{money(m.tco, true)}</div>
</div>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Saving</div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Net / yr</div>
<div className="tabnum text-[13px] font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings, true)}</div>
</div>
<div>
@@ -181,6 +195,33 @@ export default function CandidateRow({
</div>
</div>
{/* ---- Total cost and pricing ---- */}
<div className="mt-5 border-t pt-4">
<div className="mb-2 flex flex-wrap items-baseline gap-2">
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Total cost of ownership</h4>
<span className="tabnum text-[12px] text-[var(--text-secondary)]">
{money(m.licence)} licence + {money(m.infra)} infra + {money(m.admin)} admin + {money(m.other)} other = <strong>{money(m.tco)}</strong>/yr
</span>
{conf && (
<span className="ml-auto text-[11.5px] text-[var(--text-muted)]" title={conf.stale ? "Not reviewed in the last 12 months" : undefined}>
Defaults: <span style={{ color: conf.level === "high" ? "var(--success-text)" : conf.level === "medium" ? "var(--accent)" : "var(--text-muted)" }}>{conf.level} confidence</span> · {conf.source}{conf.stale ? " · needs review" : ""}
</span>
)}
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
<TcoField id={`ti-${c.id}`} label="Infrastructure (USD)" value={c.tco?.infra ?? null} fallback={Math.round(m.licence * d.infraPct / 100)}
hint={`Default ${d.infraPct}% of licence`} step={1000} onChange={(v) => setTco("infra", v)} />
<TcoField id={`ta-${c.id}`} label="Admin (FTEs)" value={c.tco?.adminFte ?? null} fallback={m.licence > 0 ? d.adminFte : 0}
hint={`${money(m.admin, true)} at the assessment FTE cost`} step={0.05} onChange={(v) => setTco("adminFte", v)} />
<TcoField id={`to-${c.id}`} label="Other run costs (USD)" value={c.tco?.other ?? null} fallback={Math.round(m.licence * d.otherPct / 100)}
hint={`Default ${d.otherPct}% — training, integrations`} step={1000} onChange={(v) => setTco("other", v)} />
<TcoField id={`ia-${c.id}`} label={<>Our price / yr <InternalBadge /></>} value={c.investment?.annual ?? null} fallback={Math.round(m.subscription)}
hint={c.alreadyEntitled ? "Already entitled — none by default" : "Blank = share of the deal-level price"} step={1000} onChange={(v) => setInv("annual", v)} />
<TcoField id={`is-${c.id}`} label={<>Our services <InternalBadge /></>} value={c.investment?.services ?? null} fallback={Math.round(m.services)}
hint="Blank = share of deal-level services" step={1000} onChange={(v) => setInv("services", v)} />
</div>
</div>
{/* ---- Scoring ---- */}
<div className="mt-5 grid gap-5 border-t pt-4 sm:grid-cols-2 lg:grid-cols-4">
<Slider id={`cap-${c.id}`} label="Capability parity" value={c.capability} min={1} max={5}
@@ -214,7 +255,7 @@ export default function CandidateRow({
</label>
<div className="ml-auto flex items-center gap-2">
<span className="tabnum text-[12px] text-[var(--text-muted)]">
{money(m.annualSavings)}/yr · {monthsLabel(m.paybackMonths)} payback
{money(m.annualSavings)}/yr net · {monthsLabel(m.paybackMonths)} payback
{m.roiPct !== null && ` · ${m.roiPct.toFixed(0)}% ROI`}
</span>
<button className="btn btn-sm" onClick={onDuplicate}>Duplicate</button>
@@ -226,3 +267,25 @@ export default function CandidateRow({
</div>
);
}
/** A cost field that shows its default until the seller types an override. */
function TcoField({
id, label, value, fallback, hint, step, onChange,
}: {
id: string; label: React.ReactNode; value: number | null; fallback: number; hint: string; step: number;
onChange: (v: number | null) => void;
}) {
return (
<div>
<label className="label" htmlFor={id}>{label}</label>
<div className="flex gap-1">
<input id={id} className="field tabnum" type="number" min={0} step={step} placeholder={String(fallback)}
value={value ?? ""} onChange={(e) => onChange(e.target.value === "" ? null : Math.max(0, Number(e.target.value)))} />
{value !== null && (
<button className="btn btn-ghost btn-sm shrink-0" title="Use the default" onClick={() => onChange(null)}>↺</button>
)}
</div>
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{value === null ? `Default · ${hint}` : hint}</div>
</div>
);
}