"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 (
{value}{suffix}
onChange(Number(e.target.value))} /> {hint &&
{hint}
}
); } 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(null); const set = (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 (
setOpen((v) => !v)}>
{title} {!c.included && Excluded} {c.alreadyEntitled && ( Already entitled )}
{mapped.length ? `→ ${mapped.map((p) => p!.name).join(", ")}` : "No replacement mapped yet"}
TCO
{money(m.tco, true)}
Net / yr
{money(m.annualSavings, true)}
Payback
{monthsLabel(m.paybackMonths)}
{m.score.toFixed(0)} {open ? "▴" : "▾"}
{open && (
{/* ---- Incumbent ---- */}

Incumbent tool

{ setApplied(null); set(field, value); }} onPick={applyEntry} /> {applied && (
Filled from the knowledge base. Adjust anything — your values are learned back.
)}
set("annualSpend", Math.max(0, Number(e.target.value)))} />
set("units", Math.max(0, Number(e.target.value)))} />
set("unitLabel", e.target.value)} />
set("renewalDate", e.target.value)} />
{/* ---- Replacement ---- */}

Our replacement

set("productIds", ids)} /> {suggestions.length > 0 && (
Suggested
{suggestions.map((p) => ( ))}
)}
set("savingsPct", Math.min(100, Math.max(0, Number(e.target.value))))} />
% of the {money(c.annualSpend, true)} line
set("migrationCost", Math.max(0, Number(e.target.value)))} />
One-time services + effort
{/* ---- Total cost and pricing ---- */}

Total cost of ownership

{money(m.licence)} licence + {money(m.infra)} infra + {money(m.admin)} admin + {money(m.other)} other = {money(m.tco)}/yr {conf && ( Defaults: {conf.level} confidence · {conf.source}{conf.stale ? " · needs review" : ""} )}
setTco("infra", v)} /> 0 ? d.adminFte : 0} hint={`${money(m.admin, true)} at the assessment FTE cost`} step={0.05} onChange={(v) => setTco("adminFte", v)} /> setTco("other", v)} /> Our price / yr } 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)} /> Our services } value={c.investment?.services ?? null} fallback={Math.round(m.services)} hint="Blank = share of deal-level services" step={1000} onChange={(v) => setInv("services", v)} />
{/* ---- Scoring ---- */}
set("capability", n)} /> set("switchEase", n)} /> = 36 ? "Beyond the scoring horizon" : "Within the scoring horizon"} onChange={(n) => set("timeToRoiMonths", n)} /> set("strategicValue", n)} />