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:
228
src/components/CandidateRow.tsx
Normal file
228
src/components/CandidateRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user