Files
Converge/src/components/CandidateRow.tsx
Chris Olson 93007a1053 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>
2026-09-24 15:41:15 -04:00

292 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
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";
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
function Slider({
id, label, value, min, max, step = 1, hint, onChange, suffix,
}: {
id: string; label: string; value: number; min: number; max: number; step?: number;
hint?: string; suffix?: string; onChange: (n: number) => void;
}) {
return (
<div>
<div className="mb-1 flex items-baseline gap-2">
<label className="label mb-0" htmlFor={id}>{label}</label>
<span className="ml-auto tabnum text-[12.5px] font-semibold">{value}{suffix}</span>
</div>
<input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
{hint && <div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>}
</div>
);
}
export default function CandidateRow({
c, m, catalog, kb, onChange, onDelete, onDuplicate,
}: {
c: Candidate; m: CandidateMetrics; catalog: Catalog; kb: KnowledgeBase | null;
onChange: (next: Candidate) => void; onDelete: () => void; onDuplicate: () => void;
}) {
const [open, setOpen] = useState(!c.incumbentProduct);
const [applied, setApplied] = useState<KbEntry | null>(null);
const set = <K extends keyof Candidate>(k: K, v: Candidate[K]) => onChange({ ...c, [k]: v });
/** Applying an entry fills the row in one write so autosave sees a single change. */
function applyEntry(e: KbEntry) {
onChange({
...c,
incumbentVendor: e.vendor,
incumbentProduct: e.product,
domain: e.domain || c.domain,
productIds: e.productIds.length ? [...e.productIds] : c.productIds,
capability: e.capability,
switchEase: e.switchEase,
timeToRoiMonths: e.timeToRoiMonths,
savingsPct: e.savingsPct,
// Spend and quantity are always customer-specific, so they are never prefilled.
migrationCost: e.migrationCost || c.migrationCost,
unitLabel: e.unitLabel || c.unitLabel,
risks: c.risks.trim() || e.risks,
tcoDefaults: { ...(e.tco ?? TCO_FALLBACK) },
});
setApplied(e);
}
const suggestions = suggest(catalog, `${c.incumbentVendor} ${c.incumbentProduct}`, c.domain)
.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"}`}>
<div className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-[var(--surface-2)]"
onClick={() => setOpen((v) => !v)}>
<span className="w-1.5 self-stretch rounded-full" style={{ background: WAVE_COLOR[m.wave - 1] }} aria-hidden />
<div className="min-w-0 flex-1">
<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"}
</div>
</div>
<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)]">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)]">Net / yr</div>
<div className="tabnum text-[13px] font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings, true)}</div>
</div>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Payback</div>
<div className="tabnum text-[13px] font-semibold">{monthsLabel(m.paybackMonths)}</div>
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1.5">
<span className="tabnum rounded-lg px-2 py-1 text-[13px] font-semibold"
style={{ background: `color-mix(in oklab, ${WAVE_COLOR[m.wave - 1]} 16%, transparent)`, color: WAVE_COLOR[m.wave - 1] }}
title={`Rationalization score ${m.score.toFixed(0)} of 100 — Wave ${m.wave}`}>
{m.score.toFixed(0)}
</span>
<span className="text-[var(--text-muted)]">{open ? "▴" : "▾"}</span>
</div>
</div>
{open && (
<div className="fade-up border-t px-4 py-4">
<div className="grid gap-4 lg:grid-cols-2">
{/* ---- Incumbent ---- */}
<div className="space-y-3">
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Incumbent tool</h4>
<IncumbentPicker
kb={kb} catalog={catalog}
vendor={c.incumbentVendor} product={c.incumbentProduct}
onType={(field, value) => { setApplied(null); set(field, value); }}
onPick={applyEntry}
/>
{applied && (
<div className="fade-up flex items-center gap-2 rounded-lg bg-[var(--surface-2)] px-2.5 py-2 text-[11.5px] text-[var(--text-secondary)]">
<ConfidenceChip entry={applied} />
<span>Filled from the knowledge base. Adjust anything — your values are learned back.</span>
</div>
)}
<div>
<label className="label" htmlFor={`d-${c.id}`}>Capability domain</label>
<select id={`d-${c.id}`} className="field" value={c.domain} onChange={(e) => set("domain", e.target.value)}>
<option value="">— choose —</option>
{catalog.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="label" htmlFor={`s-${c.id}`}>Annual spend (USD)</label>
<input id={`s-${c.id}`} className="field tabnum" type="number" min={0} step={1000} value={c.annualSpend} onChange={(e) => set("annualSpend", Math.max(0, Number(e.target.value)))} />
</div>
<div>
<label className="label" htmlFor={`u-${c.id}`}>Quantity</label>
<input id={`u-${c.id}`} className="field tabnum" type="number" min={0} value={c.units} onChange={(e) => set("units", Math.max(0, Number(e.target.value)))} />
</div>
<div>
<label className="label" htmlFor={`ul-${c.id}`}>Unit</label>
<input id={`ul-${c.id}`} className="field" placeholder="seats / cores" value={c.unitLabel} onChange={(e) => set("unitLabel", e.target.value)} />
</div>
</div>
<div>
<label className="label" htmlFor={`r-${c.id}`}>Contract renewal</label>
<input id={`r-${c.id}`} className="field" type="date" value={c.renewalDate} onChange={(e) => set("renewalDate", e.target.value)} />
</div>
</div>
{/* ---- Replacement ---- */}
<div className="space-y-3">
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Our replacement</h4>
<ProductPicker catalog={catalog} value={c.productIds} onChange={(ids) => set("productIds", ids)} />
{suggestions.length > 0 && (
<div className="rounded-lg bg-[var(--surface-2)] p-2.5">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Suggested</div>
<div className="flex flex-wrap gap-1.5">
{suggestions.map((p) => (
<button key={p.id} className="btn btn-sm" title={p.description}
onClick={() => set("productIds", [...c.productIds, p.id])}>+ {p.name}</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label" htmlFor={`sp-${c.id}`}>Spend eliminated</label>
<input id={`sp-${c.id}`} className="field tabnum" type="number" min={0} max={100} value={c.savingsPct}
onChange={(e) => set("savingsPct", Math.min(100, Math.max(0, Number(e.target.value))))} />
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">% of the {money(c.annualSpend, true)} line</div>
</div>
<div>
<label className="label" htmlFor={`mc-${c.id}`}>Migration cost (USD)</label>
<input id={`mc-${c.id}`} className="field tabnum" type="number" min={0} step={1000} value={c.migrationCost}
onChange={(e) => set("migrationCost", Math.max(0, Number(e.target.value)))} />
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">One-time services + effort</div>
</div>
</div>
</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}
hint={CAPABILITY_LABEL[c.capability]} onChange={(n) => set("capability", n)} />
<Slider id={`se-${c.id}`} label="Ease of switching" value={c.switchEase} min={1} max={5}
hint={SWITCH_LABEL[c.switchEase]} onChange={(n) => set("switchEase", n)} />
<Slider id={`roi-${c.id}`} label="Time to ROI" value={c.timeToRoiMonths} min={0} max={48} suffix=" mo"
hint={c.timeToRoiMonths <= 6 ? "Fast — scores full marks" : c.timeToRoiMonths >= 36 ? "Beyond the scoring horizon" : "Within the scoring horizon"}
onChange={(n) => set("timeToRoiMonths", n)} />
<Slider id={`sv-${c.id}`} label="Strategic value" value={c.strategicValue} min={1} max={5}
hint="Non-financial importance to the customer" onChange={(n) => set("strategicValue", n)} />
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor={`ri-${c.id}`}>Risks & dependencies</label>
<textarea id={`ri-${c.id}`} className="field" rows={2} placeholder="Integrations, skills, contract lock-in…"
value={c.risks} onChange={(e) => set("risks", e.target.value)} />
</div>
<div>
<label className="label" htmlFor={`no-${c.id}`}>Internal notes</label>
<textarea id={`no-${c.id}`} className="field" rows={2} placeholder="Not shown in the customer report"
value={c.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
<label className="flex items-center gap-2 text-[12.5px] font-medium">
<input type="checkbox" checked={c.included} onChange={(e) => set("included", e.target.checked)} />
Include in totals and the customer report
</label>
<div className="ml-auto flex items-center gap-2">
<span className="tabnum text-[12px] text-[var(--text-muted)]">
{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>
<button className="btn btn-sm btn-danger" onClick={() => { if (confirm(`Remove ${title}?`)) onDelete(); }}>Delete</button>
</div>
</div>
</div>
)}
</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>
);
}