Initial commit: Converge portfolio rationalization app

Next.js app for mapping a customer's competitive tool landscape onto our
portfolio and building the consolidation business case: assessment
workbench, customer report and Word export, product catalog, competitive
knowledge base, dossier import, shareable landscape questionnaires with a
review inbox, tool-footprint and business-unit value analysis, and an
implementation timeline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 11:48:42 -04:00
commit d5c77543e8
64 changed files with 30861 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
import type { CandidateMetrics } from "@/lib/scoring";
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,
});
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";
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>}
</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)]">Spend</div>
<div className="tabnum text-[13px] font-semibold">{money(c.annualSpend, true)}</div>
</div>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Saving</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>
{/* ---- 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 · {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>
);
}