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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user