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,130 @@
"use client";
import { useState } from "react";
import type { AccountIntel } from "@/lib/types";
import { dateLabel, num } from "@/lib/format";
/**
* Internal-only context from the account dossier.
*
* Marked `no-print` and never referenced by the customer report: the source
* document mixes customer-safe facts with internal pricing strategy, competitive
* risk and personal details.
*/
export default function AccountIntelStrip({ intel, onClear }: { intel: AccountIntel; onClear: () => void }) {
const [open, setOpen] = useState(false);
const headline = [
["Classification", intel.classification],
["Status", intel.accountStatus],
["Sentiment", intel.sentiment],
["Renewal", intel.renewalTimeframe],
["Renewal risk", intel.renewalRisk],
["Deployment", intel.deploymentRate],
].filter(([, v]) => v) as [string, string][];
const shelfware = intel.consumption.filter((c) => c.entitled > 0 && c.deployed === 0);
return (
<section className="no-print card mb-5 overflow-hidden">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<span className="chip" style={{ borderColor: "var(--warning)", color: "var(--warning)" }}>Internal only</span>
<span className="text-[13px] font-semibold">Account intelligence</span>
{intel.sourceUrl && (
<a className="max-w-[280px] truncate text-[12px] text-[var(--accent)] hover:underline"
href={intel.sourceUrl} target="_blank" rel="noreferrer noopener">
{intel.sourceTitle || "Open dossier"}
</a>
)}
<span className="text-[11.5px] text-[var(--text-muted)]">Imported {dateLabel(intel.importedAt)}</span>
<div className="ml-auto flex gap-1.5">
<button className="btn btn-ghost btn-sm" onClick={() => setOpen((v) => !v)}>{open ? "Hide" : "Details"}</button>
<button className="btn btn-ghost btn-sm btn-danger"
onClick={() => { if (confirm("Remove the imported account intelligence from this assessment?")) onClear(); }}>
Remove
</button>
</div>
</div>
{headline.length > 0 && (
<dl className="flex flex-wrap gap-x-6 gap-y-2 border-t px-4 py-2.5">
{headline.map(([k, v]) => (
<div key={k}>
<dt className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{k}</dt>
<dd className="text-[12.5px]">{v}</dd>
</div>
))}
</dl>
)}
{open && (
<div className="fade-up grid gap-4 border-t p-4 lg:grid-cols-3">
{shelfware.length > 0 && (
<Panel title={`Entitled, not deployed (${shelfware.length})`}
hint="Already paid for — adoption plays that need no new licence spend.">
<ul className="space-y-1">
{shelfware.map((s) => (
<li key={s.product} className="flex items-baseline gap-2 text-[12.5px]">
<span className="truncate font-medium">{s.product}</span>
<span className="ml-auto shrink-0 tabnum text-[var(--text-muted)]">{num(s.entitled)}</span>
</li>
))}
</ul>
</Panel>
)}
{intel.infrastructure.length > 0 && (
<Panel title="Infrastructure estate" hint="The vendor per layer, as the dossier records it.">
<ul className="space-y-1">
{[...intel.infrastructure, ...intel.cloud].map((f) => (
<li key={f.label} className="flex items-baseline gap-2 text-[12.5px]">
<span className="shrink-0 text-[var(--text-muted)]">{f.label}</span>
<span className="ml-auto truncate text-right font-medium">{f.value}</span>
</li>
))}
</ul>
</Panel>
)}
{intel.applications.length > 0 && (
<Panel title={`Application estate (${intel.applications.length})`} hint="Grouped as the dossier groups them.">
<ul className="scroll-thin max-h-[190px] space-y-1 overflow-y-auto">
{intel.applications.map((app) => (
<li key={`${app.category}-${app.name}`} className="flex items-baseline gap-2 text-[12.5px]">
<span className="truncate">{app.name}</span>
<span className="ml-auto shrink-0 text-[11px] text-[var(--text-muted)]">{app.category}</span>
</li>
))}
</ul>
</Panel>
)}
{intel.narratives.length > 0 && (
<div className="lg:col-span-3">
<Panel title="Analyst assessment" hint="Background for the executive summary — not customer-facing copy.">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{intel.narratives.map((n) => (
<div key={n.heading}>
<div className="text-[12px] font-semibold">{n.heading}</div>
<p className="mt-0.5 text-[12px] leading-relaxed text-[var(--text-secondary)]">{n.body}</p>
</div>
))}
</div>
</Panel>
</div>
)}
</div>
)}
</section>
);
}
function Panel({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
return (
<div className="rounded-xl bg-[var(--surface-2)] p-3">
<h4 className="text-[12px] font-semibold">{title}</h4>
<p className="mb-2 text-[11px] text-[var(--text-muted)]">{hint}</p>
{children}
</div>
);
}

306
src/components/Analysis.tsx Normal file
View File

@@ -0,0 +1,306 @@
"use client";
import { useMemo, useState } from "react";
import type { Assessment, Catalog } from "@/lib/types";
import type { Rollup } from "@/lib/scoring";
import { money } from "@/lib/format";
import { ChartFrame, DataTable, Empty, StatTile } from "@/components/charts";
import { type Plan, type PlanItem, DEFAULT_PARALLEL, DURATION_BY_EASE, buildPlan, monthLabel, quarterLabel } from "@/lib/timeline";
/* -------------------------------------------------------- tool footprint */
export function ToolFootprintTile({ r }: { r: Rollup }) {
const cut = r.toolsRetired - r.toolsAfter;
const pct = r.toolsRetired ? Math.round((cut / r.toolsRetired) * 100) : 0;
return (
<StatTile
label="Tool footprint"
value={`${r.toolsRetired} → ${r.toolsAfter}`}
tone={cut > 0 ? "good" : "neutral"}
sub={`${cut > 0 ? `${cut} fewer tools (${pct}%)` : cut < 0 ? `${-cut} more products than tools` : "Same tool count"} · ${r.vendorsRetired} vendors → ${r.vendorsAfter}${r.unmappedCount ? ` · ${r.unmappedCount} unmapped` : ""}`}
/>
);
}
/* ------------------------------------------------- value by business unit */
export function BuBreakdown({ r, catalog, horizonYears }: { r: Rollup; catalog: Catalog; horizonYears: number }) {
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
const total = r.byBu.reduce((s, b) => s + b.savings, 0);
const max = Math.max(1, ...r.byBu.map((b) => b.savings));
return (
<ChartFrame
title="Value by business unit"
subtitle={`Where the displaced spend lands in our portfolio. A tool mapped to two units splits its value between them.`}
table={{
headers: ["Business unit", "Tools displaced", "Our products", "Current spend", "Annual saving", "Migration", `${horizonYears}-yr net`],
rows: r.byBu.map((b) => [b.name, b.count, b.productIds.length, money(b.spend), money(b.savings), money(b.migrationCost), money(b.horizonNet)]),
}}
>
{r.byBu.length === 0 ? <Empty>Map replacement products to see this.</Empty> : (
<>
{/* share of annual saving, as one stacked bar */}
{total > 0 && (
<div className="mb-4 flex h-2.5 w-full overflow-hidden rounded-full bg-[var(--grid)]" role="img"
aria-label={r.byBu.map((b) => `${b.short} ${Math.round((b.savings / total) * 100)}%`).join(", ")}>
{r.byBu.filter((b) => b.savings > 0).map((b) => (
<div key={b.buId} style={{ width: `${(b.savings / total) * 100}%`, background: b.color }}
title={`${b.name}: ${Math.round((b.savings / total) * 100)}% of annual saving`} />
))}
</div>
)}
<ul className="divide-y divide-[var(--grid)]">
{r.byBu.map((b) => (
<li key={b.buId} className="grid items-center gap-x-4 gap-y-1 py-2.5 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_repeat(3,96px)]">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: b.color }} />
<span className="truncate text-[13px] font-semibold">{b.name}</span>
</div>
<div className="mt-0.5 truncate pl-[18px] text-[11.5px] text-[var(--text-muted)]" title={b.productIds.map(prodName).join(", ")}>
{b.count} tool{b.count === 1 ? "" : "s"} → {b.productIds.map(prodName).join(", ")}
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--grid)]">
<div className="h-full rounded-full" style={{ width: `${Math.max(1.5, (b.savings / max) * 100)}%`, background: b.color }} />
</div>
<Num label="Spend" v={money(b.spend, true)} />
<Num label="Saving / yr" v={money(b.savings, true)} strong />
<Num label={`${horizonYears}-yr net`} v={money(b.horizonNet, true)} />
</li>
))}
</ul>
</>
)}
</ChartFrame>
);
}
function Num({ label, v, strong }: { label: string; v: string; strong?: boolean }) {
return (
<div className="text-right">
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
<div className={`tabnum text-[13px] ${strong ? "font-semibold" : ""}`} style={strong ? { color: "var(--success-text)" } : undefined}>{v}</div>
</div>
);
}
/* --------------------------------------------------- implementation timeline */
const PHASE_MONTHS = 6;
export function ImplementationTimeline({
a, r, catalog, onParallel,
}: {
a: Assessment;
r: Rollup;
catalog: Catalog;
/** Workbench only: lets the seller tune how many migrations run at once. */
onParallel?: (n: number) => void;
}) {
const [open, setOpen] = useState(false);
const [asTable, setAsTable] = useState(false);
const plan = useMemo(() => buildPlan(a, r, catalog), [a, r, catalog]);
const L = (n: number, long = false) => monthLabel(plan.startIso, n, long);
const summary = plan.items.length
? `${plan.items.length} projects · ${L(0)} – ${L(plan.completion - 1)} · full run-rate savings from ${L(plan.fullRunRate)}`
: "Map replacement products to build a plan.";
return (
<section className="card avoid-break mb-5 overflow-hidden">
<button className="flex w-full items-center gap-3 px-5 py-4 text-left hover:bg-[var(--surface-2)]"
aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<span className="text-[12px] text-[var(--text-muted)] transition-transform no-print" style={{ transform: open ? "rotate(90deg)" : "none" }}>▶</span>
<span className="min-w-0">
<span className="block text-[14px] font-semibold tracking-tight">Implementation timeline</span>
<span className="block truncate text-[12.5px] text-[var(--text-muted)]">{summary}</span>
</span>
{plan.bridgeCount > 0 && (
<span className="chip ml-auto shrink-0" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>
{plan.bridgeCount} need a bridge extension
</span>
)}
</button>
{/* Collapsed on screen, but always part of the printed report. */}
<div className={open ? "border-t p-5" : "hidden border-t p-5 print:block"}>
{plan.items.length === 0 ? <Empty>Map replacement products to build a plan.</Empty> : (
<>
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Mini label="Program" value={`${plan.completion} months`} sub={`${L(0)} – ${L(plan.completion - 1)}`} />
<Mini label="Full run-rate savings" value={L(plan.fullRunRate)} sub={`${money(plan.ramp[plan.ramp.length - 1] ?? 0, true)} per year once complete`} />
<Mini label={`Realized in ${a.horizonYears} years`} value={money(plan.realizedInHorizon, true)}
sub={`${plan.flatInHorizon ? Math.round((plan.realizedInHorizon / plan.flatInHorizon) * 100) : 0}% of the ${money(plan.flatInHorizon, true)} flat model, after ramp-up`} />
<Mini label="Contract-driven" value={`${plan.items.filter((i) => i.anchor === "contract").length} of ${plan.items.length}`}
sub={plan.bridgeCount ? `${plan.bridgeCount} expire before migration can finish` : "Every dated contract is met"} />
</div>
<div className="no-print mb-3 flex flex-wrap items-center gap-2">
{onParallel && (
<>
<span className="text-[12px] text-[var(--text-secondary)]">Parallel migrations per business unit</span>
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
{[1, 2, 3, 4].map((n) => (
<button key={n} onClick={() => onParallel(n)}
className={`px-2.5 py-1 text-[12px] font-semibold ${(a.planParallel ?? DEFAULT_PARALLEL) === n ? "bg-[var(--accent)] text-white" : "hover:bg-[var(--surface-2)]"}`}>{n}</button>
))}
</div>
</>
)}
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setAsTable((v) => !v)}>{asTable ? "Timeline" : "Table"}</button>
</div>
{asTable ? <PlanTable plan={plan} /> : <Gantt plan={plan} catalog={catalog} />}
<p className="mt-4 text-[11.5px] leading-relaxed text-[var(--text-muted)]">
Tools with a known contract end are scheduled to cut over by that date. The rest are sequenced with known
savings first, then by wave and size of saving, with {a.planParallel ?? DEFAULT_PARALLEL} migration{(a.planParallel ?? DEFAULT_PARALLEL) === 1 ? "" : "s"} in
flight per business unit. Migration length follows ease of switching ({Object.entries(DURATION_BY_EASE).reverse().map(([k, v]) => `${k}/5: ${v} mo`).join(", ")}).
Savings start at cutover, or at contract end if later.
{plan.unmappedCount > 0 && ` ${plan.unmappedCount} unmapped tool${plan.unmappedCount === 1 ? " is" : "s are"} left out until a replacement is chosen.`}
</p>
</>
)}
</div>
</section>
);
}
function Mini({ label, value, sub }: { label: string; value: string; sub: string }) {
return (
<div className="rounded-xl bg-[var(--surface-2)] px-3.5 py-3">
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
<div className="mt-1 text-[18px] font-semibold tracking-tight">{value}</div>
<div className="mt-0.5 text-[11.5px] text-[var(--text-secondary)]">{sub}</div>
</div>
);
}
function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
const span = plan.span;
const pct = (m: number) => `${(Math.min(Math.max(m, 0), span) / span) * 100}%`;
// Quarter ticks, labelled at each quarter boundary inside the span.
const ticks: number[] = [];
const firstMonth = new Date(`${plan.startIso}T00:00:00Z`).getUTCMonth();
for (let m = 0; m <= span; m++) if ((firstMonth + m) % 3 === 0) ticks.push(m);
const phases = new Map<number, PlanItem[]>();
for (const i of plan.items) {
const p = Math.floor(i.start / PHASE_MONTHS);
phases.set(p, [...(phases.get(p) ?? []), i]);
}
const buLegend = catalog.businessUnits.filter((b) => plan.items.some((i) => i.color === b.color));
const peak = Math.max(1, ...plan.ramp);
const Track = ({ children, className = "" }: { children?: React.ReactNode; className?: string }) => (
<div className={`relative ${className}`}>
{ticks.map((m) => <div key={m} className="absolute inset-y-0 w-px bg-[var(--grid)]" style={{ left: pct(m) }} />)}
{children}
</div>
);
return (
<div className="scroll-thin overflow-x-auto">
<div className="min-w-[760px]">
{/* axis */}
<div className="grid grid-cols-[240px_minmax(0,1fr)] border-b pb-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Project</div>
<div className="relative h-4">
{ticks.map((m) => (
<span key={m} className="absolute -translate-x-1/2 whitespace-nowrap text-[10.5px] tabnum text-[var(--text-muted)]" style={{ left: pct(m) }}>
{quarterLabel(plan.startIso, m)}
</span>
))}
</div>
</div>
{[...phases.entries()].map(([p, items]) => (
<div key={p}>
<div className="grid grid-cols-[240px_minmax(0,1fr)] bg-[var(--surface-2)]">
<div className="px-1 py-1.5 text-[11px] font-semibold">
Phase {p + 1} <span className="font-normal text-[var(--text-muted)]">· starts {monthLabel(plan.startIso, p * PHASE_MONTHS)} – {monthLabel(plan.startIso, p * PHASE_MONTHS + PHASE_MONTHS - 1)}</span>
</div>
<div className="self-center pr-1 text-right text-[11px] tabnum text-[var(--text-secondary)]">
{items.length} project{items.length === 1 ? "" : "s"} · {money(items.reduce((s, i) => s + i.annualSavings, 0), true)}/yr unlocked
</div>
</div>
{items.map((i) => (
<div key={i.id} className="grid grid-cols-[240px_minmax(0,1fr)] border-b border-[var(--grid)]">
<div className="min-w-0 py-1.5 pr-3">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12.5px] font-medium">{i.label}</span>
{i.bridge && <span className="shrink-0 text-[11px]" style={{ color: "var(--warning)" }} title="Contract ends before this migration can finish — plan a short bridge extension">⚠</span>}
</div>
<div className="truncate text-[11px] text-[var(--text-muted)]">→ {i.products}</div>
</div>
<Track className="h-full min-h-[38px]">
{/* waiting for the contract to run out before savings start */}
{i.savingsFrom > i.end && (
<div className="absolute top-1/2 h-0 -translate-y-1/2 border-t-2 border-dashed" style={{ left: pct(i.end), width: `calc(${pct(i.savingsFrom)} - ${pct(i.end)})`, borderColor: i.color, opacity: 0.6 }} />
)}
<div className="absolute top-1/2 flex h-4 -translate-y-1/2 items-center rounded-[5px]"
title={`${i.label}: ${monthLabel(plan.startIso, i.start)} → cutover ${monthLabel(plan.startIso, i.end - 1)} · ${money(i.annualSavings)}/yr from ${monthLabel(plan.startIso, i.savingsFrom)}`}
style={{
left: pct(i.start), width: `calc(${pct(i.end)} - ${pct(i.start)})`, background: i.color,
...(i.bridge ? { backgroundImage: "repeating-linear-gradient(45deg, transparent 0 5px, rgba(255,255,255,.35) 5px 8px)" } : {}),
}} />
{i.annualSavings > 0 && (
<span className="absolute top-1/2 ml-1.5 -translate-y-1/2 whitespace-nowrap text-[10.5px] tabnum text-[var(--text-secondary)]"
style={{ left: pct(Math.max(i.end, i.savingsFrom)) }}>{money(i.annualSavings, true)}/yr</span>
)}
{i.contractEnd !== null && (
<span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 border-2"
style={{ left: pct(i.contractEnd + 1), borderColor: "var(--text-primary)", background: "var(--surface-1)" }}
title={`Contract ends ${monthLabel(plan.startIso, i.contractEnd, true)}`} />
)}
</Track>
</div>
))}
</div>
))}
{/* run-rate savings ramp */}
<div className="grid grid-cols-[240px_minmax(0,1fr)] pt-2">
<div className="pr-3 text-[11px]">
<div className="font-semibold">Run-rate savings</div>
<div className="text-[var(--text-muted)]">{money(peak, true)}/yr at full run-rate</div>
</div>
<Track className="h-14">
<svg className="absolute inset-0 h-full w-full" viewBox={`0 0 ${span} 100`} preserveAspectRatio="none" aria-hidden>
<path
d={`M0 100 ${plan.ramp.map((v, m) => `L${m} ${100 - (v / peak) * 92} L${m + 1} ${100 - (v / peak) * 92}`).join(" ")} L${span} ${100 - ((plan.ramp[plan.ramp.length - 1] ?? 0) / peak) * 92} L${span} 100 Z`}
fill="var(--good)" fillOpacity=".18" stroke="var(--success-text)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
</Track>
</div>
{/* legend */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] text-[var(--text-secondary)]">
{buLegend.map((b) => (
<span key={b.id} className="flex items-center gap-1.5"><span className="h-2.5 w-4 rounded-sm" style={{ background: b.color }} />{b.short}</span>
))}
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rotate-45 border-2 border-[var(--text-primary)]" />Contract ends</span>
<span className="flex items-center gap-1.5"><span className="w-4 border-t-2 border-dashed border-[var(--text-muted)]" />Waiting for contract end</span>
<span className="flex items-center gap-1.5"><span style={{ color: "var(--warning)" }}>⚠</span>Bridge extension needed</span>
</div>
</div>
</div>
);
}
function PlanTable({ plan }: { plan: Plan }) {
const L = (n: number) => monthLabel(plan.startIso, n);
return (
<DataTable
headers={["Project", "Replacement", "Start", "Cutover", "Contract ends", "Savings from", "Annual saving"]}
rows={plan.items.map((i) => [
`${i.label}${i.bridge ? " ⚠" : ""}`, i.products, L(i.start), L(i.end - 1),
i.contractEnd === null ? "—" : L(i.contractEnd), L(i.savingsFrom), money(i.annualSavings),
])}
/>
);
}

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>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
import { searchKb } from "@/lib/kb";
/**
* Typeahead over the competitive knowledge base.
*
* Typing filters known incumbent tools; picking one applies its learned
* defaults — product mapping, scores, savings and migration cost — in one go.
*/
export default function IncumbentPicker({
kb, catalog, vendor, product, onType, onPick,
}: {
kb: KnowledgeBase | null;
catalog: Catalog;
vendor: string;
product: string;
onType: (field: "incumbentVendor" | "incumbentProduct", value: string) => void;
onPick: (entry: KbEntry) => void;
}) {
const [open, setOpen] = useState(false);
const [focus, setFocus] = useState<"vendor" | "product" | null>(null);
const [cursor, setCursor] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
const query = [vendor, product].filter(Boolean).join(" ");
const matches = useMemo(() => (kb ? searchKb(kb, query, 8) : []), [kb, query]);
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
useEffect(() => { setCursor(0); }, [query]);
function choose(e: KbEntry) {
onPick(e);
setOpen(false);
setFocus(null);
}
function onKeyDown(ev: React.KeyboardEvent) {
if (!open || matches.length === 0) return;
if (ev.key === "ArrowDown") { ev.preventDefault(); setCursor((c) => (c + 1) % matches.length); }
else if (ev.key === "ArrowUp") { ev.preventDefault(); setCursor((c) => (c - 1 + matches.length) % matches.length); }
else if (ev.key === "Enter") { ev.preventDefault(); choose(matches[cursor]); }
else if (ev.key === "Escape") { setOpen(false); }
}
return (
<div className="relative" ref={boxRef}
onBlur={(e) => { if (!boxRef.current?.contains(e.relatedTarget as Node)) setOpen(false); }}>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label" htmlFor={`v-${vendor}-${product}`}>Vendor</label>
<input
className="field" placeholder="e.g. Splunk" value={vendor}
aria-label="Incumbent vendor" autoComplete="off"
onChange={(e) => { onType("incumbentVendor", e.target.value); setOpen(true); }}
onFocus={() => { setFocus("vendor"); setOpen(true); }}
onKeyDown={onKeyDown}
/>
</div>
<div>
<label className="label">Product</label>
<input
className="field" placeholder="e.g. Enterprise" value={product}
aria-label="Incumbent product" autoComplete="off"
onChange={(e) => { onType("incumbentProduct", e.target.value); setOpen(true); }}
onFocus={() => { setFocus("product"); setOpen(true); }}
onKeyDown={onKeyDown}
/>
</div>
</div>
{open && focus && matches.length > 0 && (
<div className="absolute z-30 mt-1.5 w-full rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-1.5 shadow-[var(--shadow)]">
<div className="px-2 pb-1 pt-0.5 text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">
Known incumbents — picking one fills the whole row
</div>
<ul className="scroll-thin max-h-[290px] overflow-y-auto">
{matches.map((e, i) => (
<li key={e.id}>
<button
type="button"
className={`flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left ${i === cursor ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"}`}
onMouseEnter={() => setCursor(i)}
onClick={() => choose(e)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{e.label}</span>
<span className="block truncate text-[11.5px] text-[var(--text-muted)]">
{e.productIds.length ? `→ ${e.productIds.map(prodName).join(", ")}` : "No mapping yet"}
</span>
</span>
<ConfidenceChip entry={e} />
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
export function ConfidenceChip({ entry }: { entry: KbEntry }) {
const n = entry.observations.length;
if (entry.pinned) {
return <span className="chip shrink-0" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }} title="Curated entry — auto-learning will not overwrite it">Curated</span>;
}
if (n === 0) {
return <span className="chip shrink-0" title="Seeded from the product catalog; not yet used in a deal">Seeded</span>;
}
return (
<span className="chip shrink-0" style={{ borderColor: "var(--accent)", color: "var(--accent)" }}
title={`Learned from ${n} assessment${n === 1 ? "" : "s"}`}>
{n} deal{n === 1 ? "" : "s"}
</span>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import type { Catalog, IntelImportResult, IntelProposal } from "@/lib/types";
import { errorMessage, sendJson } from "@/lib/client";
import { num } from "@/lib/format";
const CONFIDENCE_STYLE: Record<IntelProposal["confidence"], { color: string; label: string }> = {
high: { color: "var(--success-text)", label: "High" },
medium: { color: "var(--warning)", label: "Medium" },
low: { color: "var(--text-muted)", label: "Low" },
};
export default function IntelImport({
catalog, onClose, onAccept,
}: {
catalog: Catalog;
onClose: () => void;
onAccept: (result: IntelImportResult, chosen: IntelProposal[]) => void;
}) {
const [url, setUrl] = useState("");
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [result, setResult] = useState<IntelImportResult | null>(null);
const [picked, setPicked] = useState<Set<string>>(new Set());
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
async function analyse() {
setBusy(true); setErr("");
try {
const r = await sendJson<IntelImportResult>("/api/assessments/x/intel", "POST", { text, sourceUrl: url });
setResult(r);
// Low-confidence guesses start unticked; the seller opts in to those.
setPicked(new Set(r.proposals.filter((p) => p.confidence !== "low").map((p) => p.key)));
} catch (e) {
setErr(errorMessage(e));
} finally {
setBusy(false);
}
}
const toggle = (k: string) =>
setPicked((prev) => {
const next = new Set(prev);
next.has(k) ? next.delete(k) : next.add(k);
return next;
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Import account intelligence"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
<div className="flex items-center gap-3 border-b px-5 py-3.5">
<h2 className="text-[15px] font-semibold tracking-tight">Import account intelligence</h2>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
</div>
{!result ? (
<div className="space-y-4 p-5">
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
Open the account dossier, select all (<kbd className="rounded bg-[var(--surface-2)] px-1">⌘A</kbd>) and copy
(<kbd className="rounded bg-[var(--surface-2)] px-1">⌘C</kbd>), then paste below. The dossier stays internal —
only the rows you accept become part of the assessment, and none of its commentary reaches the customer report.
</p>
<div>
<label className="label" htmlFor="intel-url">Dossier link</label>
<input id="intel-url" className="field" placeholder="https://docs.google.com/document/d/…"
value={url} onChange={(e) => setUrl(e.target.value)} />
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
Stored on the assessment so it always points back at its source.
</p>
</div>
<div>
<label className="label" htmlFor="intel-text">Dossier text</label>
<textarea id="intel-text" className="field font-mono text-[12px]" rows={12}
placeholder="Paste the full dossier here…" value={text} onChange={(e) => setText(e.target.value)} />
<p className="mt-1 tabnum text-[11.5px] text-[var(--text-muted)]">
{text.length ? `${num(text.length)} characters` : "Nothing pasted yet"}
</p>
</div>
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
<div className="flex justify-end gap-2">
<button className="btn" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={analyse} disabled={!text.trim() || busy}>
{busy ? "Analysing…" : "Analyse dossier"}
</button>
</div>
</div>
) : (
<div className="p-5">
<Summary result={result} />
{result.warnings.length > 0 && (
<ul className="mb-4 space-y-1 rounded-lg border px-3 py-2.5" style={{ borderColor: "var(--warning)" }}>
{result.warnings.map((w) => (
<li key={w} className="text-[12.5px]" style={{ color: "var(--text-secondary)" }}>⚠ {w}</li>
))}
</ul>
)}
{result.proposals.length > 0 && (
<>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-[13px] font-semibold">
Incumbent tools found — {picked.size} of {result.proposals.length} selected
</h3>
<button className="btn btn-ghost btn-sm ml-auto"
onClick={() => setPicked(new Set(picked.size === result.proposals.length ? [] : result.proposals.map((p) => p.key)))}>
{picked.size === result.proposals.length ? "Clear all" : "Select all"}
</button>
</div>
<ul className="scroll-thin mb-4 max-h-[300px] space-y-1.5 overflow-y-auto">
{result.proposals.map((p) => {
const c = CONFIDENCE_STYLE[p.confidence];
return (
<li key={p.key}>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 hover:bg-[var(--surface-2)]">
<input type="checkbox" className="mt-0.5" checked={picked.has(p.key)} onChange={() => toggle(p.key)} />
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="text-[13px] font-medium">{p.label}</span>
<span className="chip" style={{ borderColor: c.color, color: c.color }}>{c.label}</span>
</span>
<span className="mt-0.5 block text-[12px] text-[var(--text-secondary)]">
→ {p.productIds.length ? p.productIds.map(prodName).join(", ") : "No enabled replacement — map it manually"}
</span>
<span className="mt-0.5 block text-[11.5px] text-[var(--text-muted)]">
Dossier says — {p.evidence}
</span>
</span>
</label>
</li>
);
})}
</ul>
</>
)}
{result.shelfware.length > 0 && (
<div className="mb-4 rounded-lg bg-[var(--surface-2)] p-3">
<h3 className="mb-1.5 text-[13px] font-semibold">
Entitled but not deployed — {result.shelfware.length} product{result.shelfware.length === 1 ? "" : "s"}
</h3>
<p className="mb-2 text-[12px] text-[var(--text-secondary)]">
Already paid for. These are adoption plays, not displacements, so they are kept separately from the candidates.
</p>
<ul className="flex flex-wrap gap-1.5">
{result.shelfware.map((s) => (
<li key={s.product} className="chip">{s.product} · {num(s.entitled)} entitled</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2 border-t pt-4">
<button className="btn" onClick={() => setResult(null)}>Back</button>
<button className="btn btn-primary"
onClick={() => onAccept(result, result.proposals.filter((p) => picked.has(p.key)))}>
Add {picked.size} tool{picked.size === 1 ? "" : "s"} &amp; save intelligence
</button>
</div>
</div>
)}
</div>
</div>
);
}
function Summary({ result }: { result: IntelImportResult }) {
const i = result.intel;
const facts = [
["Company", i.company], ["Industry", i.industry], ["Classification", i.classification],
["Renewal", i.renewalTimeframe], ["Renewal risk", i.renewalRisk], ["Sentiment", i.sentiment],
["Deployment rate", i.deploymentRate], ["Applications", i.applications.length ? String(i.applications.length) : ""],
].filter(([, v]) => v);
return (
<div className="mb-4 rounded-lg border p-3">
<h3 className="mb-2 text-[13px] font-semibold">{i.sourceTitle || "Dossier"}</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-4">
{facts.map(([k, v]) => (
<div key={k}>
<dt className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{k}</dt>
<dd className="truncate text-[12.5px]" title={v}>{v}</dd>
</div>
))}
</dl>
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
export default function LoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="card p-10 text-center">
<p className="text-[14px] font-semibold" style={{ color: "var(--critical)" }}>Could not load this page</p>
<p className="mx-auto mt-2 max-w-[54ch] text-[13px] text-[var(--text-secondary)]">{message}</p>
<div className="mt-5 flex justify-center gap-2">
<button className="btn btn-primary" onClick={onRetry}>Retry</button>
<Link className="btn" href="/">Back to assessments</Link>
</div>
</div>
);
}

26
src/components/Logo.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { useId } from "react";
/**
* App mark: rising bars under an upward trend line — consolidation turning into
* savings. Fixed colours rather than theme tokens so it reads the same on light
* and dark, and matches the favicon (src/app/icon.svg).
*/
export default function Logo({ size = 24 }: { size?: number }) {
const id = useId();
return (
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden>
<defs>
<linearGradient id={id} x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
<stop stopColor="#2a78d6" />
<stop offset="1" stopColor="#1baf7a" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill={`url(#${id})`} />
<rect x="7" y="19" width="4" height="6" rx="1" fill="#fff" fillOpacity=".55" />
<rect x="14" y="15" width="4" height="10" rx="1" fill="#fff" fillOpacity=".75" />
<rect x="21" y="11" width="4" height="14" rx="1" fill="#fff" />
<path d="M6 15.5 12.5 10.5 17.5 12.5 25.5 5.5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 5.5h4.5V10" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

56
src/components/Nav.tsx Normal file
View File

@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Logo from "./Logo";
import ThemeToggle from "./ThemeToggle";
const LINKS = [
{ href: "/", label: "Assessments" },
{ href: "/knowledge-base", label: "Knowledge base" },
{ href: "/catalog", label: "Product catalog" },
];
export default function Nav() {
const path = usePathname();
const isReport = path.endsWith("/report");
// Questionnaire respondents get a standalone page with no route into the tool.
if (path.startsWith("/q/")) return null;
return (
<header
className={`no-print sticky top-0 z-40 mb-6 border-b bg-[var(--plane)]/85 backdrop-blur-xl ${isReport ? "print:hidden" : ""}`}
>
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-2 px-4 sm:px-6">
<Link href="/" className="mr-3 flex items-center gap-2.5">
<Logo size={24} />
<span className="text-[14px] font-semibold tracking-tight">Converge</span>
</Link>
<nav className="flex items-center gap-1">
{LINKS.map((l) => {
const active = l.href === "/" ? path === "/" || path.startsWith("/assessments") : path.startsWith(l.href);
return (
<Link
key={l.href}
href={l.href}
className={`rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors ${
active
? "bg-[var(--surface-2)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)]"
}`}
>
{l.label}
</Link>
);
})}
</nav>
<div className="ml-auto">
<ThemeToggle />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useMemo, useRef, useState } from "react";
import type { Catalog } from "@/lib/types";
/** Ranks catalog products against free-text about the incumbent tool. */
export function suggest(catalog: Catalog, incumbent: string, domain: string, limit = 6) {
const needle = incumbent.trim().toLowerCase();
if (!needle) return [];
const words = needle.split(/\s+/).filter((w) => w.length > 2);
return catalog.products
.filter((p) => p.enabled)
.map((p) => {
let score = 0;
for (const r of p.replaces) {
const rl = r.toLowerCase();
if (rl === needle) score += 100;
else if (rl.includes(needle) || needle.includes(rl)) score += 60;
else if (words.some((w) => rl.includes(w))) score += 22;
}
if (domain && p.domain === domain) score += 12;
return { p, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score || a.p.name.localeCompare(b.p.name))
.slice(0, limit)
.map((x) => x.p);
}
export default function ProductPicker({
catalog, value, onChange,
}: {
catalog: Catalog; value: string[]; onChange: (ids: string[]) => void;
}) {
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const byId = useMemo(() => new Map(catalog.products.map((p) => [p.id, p])), [catalog]);
const buById = useMemo(() => new Map(catalog.businessUnits.map((b) => [b.id, b])), [catalog]);
const matches = useMemo(() => {
const needle = q.trim().toLowerCase();
return catalog.products
.filter((p) => p.enabled && !value.includes(p.id))
.filter((p) => !needle || p.name.toLowerCase().includes(needle) || p.replaces.some((r) => r.toLowerCase().includes(needle)))
.slice(0, 40);
}, [catalog, q, value]);
return (
<div className="relative" ref={boxRef}
onBlur={(e) => { if (!boxRef.current?.contains(e.relatedTarget as Node)) setOpen(false); }}>
<div className="flex flex-wrap gap-1.5">
{value.map((id) => {
const p = byId.get(id);
const b = p ? buById.get(p.bu) : undefined;
return (
<span key={id} className="chip" style={{ borderColor: b?.color }}>
<span className="inline-block h-2 w-2 rounded-full" style={{ background: b?.color ?? "var(--text-muted)" }} />
{p?.name ?? id}
<button className="ml-0.5 text-[var(--text-muted)] hover:text-[var(--critical)]"
aria-label={`Remove ${p?.name ?? id}`} onClick={() => onChange(value.filter((v) => v !== id))}>×</button>
</span>
);
})}
<button className="btn btn-sm" onClick={() => setOpen((v) => !v)}>+ Map product</button>
</div>
{open && (
<div className="absolute z-30 mt-2 w-[min(440px,92vw)] rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-2 shadow-[var(--shadow)]">
<input autoFocus className="field mb-2" placeholder="Search our portfolio…" value={q} onChange={(e) => setQ(e.target.value)} />
<div className="scroll-thin max-h-[300px] overflow-y-auto">
{matches.length === 0 ? (
<p className="px-2 py-4 text-center text-[12.5px] text-[var(--text-muted)]">
No enabled products match. Check the catalog filters.
</p>
) : matches.map((p) => {
const b = buById.get(p.bu);
return (
<button key={p.id}
className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-[var(--surface-2)]"
onClick={() => { onChange([...value, p.id]); setQ(""); }}>
<span className="mt-[5px] inline-block h-2 w-2 shrink-0 rounded-full" style={{ background: b?.color }} />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{p.name}</span>
<span className="block truncate text-[11.5px] text-[var(--text-muted)]">{b?.short} · {p.description}</span>
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { useMemo, useState } from "react";
import type { Catalog, Survey } from "@/lib/types";
import { errorMessage, sendJson } from "@/lib/client";
import { answeredCount, pendingTools, surveyTools, surveyableDomains } from "@/lib/survey";
import { dateLabel } from "@/lib/format";
/** Create, share and track landscape questionnaires for one assessment. */
export default function SurveyPanel({
assessmentId, catalog, surveys, onChanged, onReview, onClose,
}: {
assessmentId: string;
catalog: Catalog;
surveys: Survey[];
onChanged: () => void;
onReview: (s: Survey) => void;
onClose: () => void;
}) {
const domains = useMemo(() => surveyableDomains(catalog), [catalog]);
const [creating, setCreating] = useState(surveys.length === 0);
const [name, setName] = useState("");
const [picked, setPicked] = useState<Set<string>>(() => new Set(domains.map((d) => d.id)));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [copied, setCopied] = useState("");
const base = `/api/assessments/${assessmentId}/surveys`;
const origin = typeof window === "undefined" ? "" : window.location.origin;
const isLocal = /\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(origin);
const linkFor = (s: Survey) => `${origin}/q/${s.id}`;
async function run(fn: () => Promise<unknown>) {
setBusy(true); setErr("");
try { await fn(); onChanged(); } catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
}
function create() {
run(async () => {
const s = await sendJson<Survey>(base, "POST", { respondentName: name, domains: [...picked] });
setCreating(false); setName("");
copy(s);
});
}
async function copy(s: Survey) {
try {
await navigator.clipboard.writeText(linkFor(s));
setCopied(s.id);
setTimeout(() => setCopied((c) => (c === s.id ? "" : c)), 2000);
} catch {
// Clipboard can be blocked; the link is visible to copy by hand.
}
}
const toggle = (id: string) => setPicked((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Landscape questionnaires"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
<div className="flex items-center gap-3 border-b px-5 py-3.5">
<h2 className="text-[15px] font-semibold tracking-tight">Landscape questionnaires</h2>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
</div>
<div className="space-y-4 p-5">
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
Send a colleague a link. They step through one category at a time, pick the tools in place, and add
spend and contract dates if they know them. Submitted answers wait in a review inbox here. Nothing reaches the
assessment until you accept it.
</p>
{isLocal && (
<p className="rounded-lg border px-3 py-2 text-[12.5px]" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>
⚠ You&rsquo;re on <code>{origin}</code>, so links will point at <em>localhost</em> and only work on this machine.
Open the app using this computer&rsquo;s network name or IP address before copying links for colleagues.
</p>
)}
{surveys.length > 0 && (
<ul className="space-y-2">
{surveys.map((s) => {
const total = s.domains.length;
const done = answeredCount(s);
const pending = pendingTools(s).length;
const tools = surveyTools(s).length;
return (
<li key={s.id} className="rounded-xl border p-3.5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[13.5px] font-semibold">{s.respondentName || "Unnamed respondent"}</span>
{s.status === "submitted" ? (
pending > 0
? <span className="chip" style={{ borderColor: "var(--accent)", color: "var(--accent)" }}>Submitted · {pending} to review</span>
: <span className="chip" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }}>Reviewed</span>
) : (
<span className="chip">{done ? `In progress · ${done}/${total}` : "Not started"}</span>
)}
<span className="text-[12px] text-[var(--text-muted)]">
{total} categories · {tools} tool{tools === 1 ? "" : "s"} named
{s.submittedAt ? ` · submitted ${dateLabel(s.submittedAt)}` : ` · created ${dateLabel(s.createdAt)}`}
</span>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<input className="field max-w-[420px] py-1 font-mono text-[11.5px]" readOnly value={linkFor(s)}
aria-label="Questionnaire link" onFocus={(e) => e.currentTarget.select()} />
<button className="btn btn-sm" onClick={() => copy(s)}>{copied === s.id ? "Copied ✓" : "Copy link"}</button>
<a className="btn btn-sm" href={`/q/${s.id}`} target="_blank" rel="noreferrer">Open ↗</a>
<span className="ml-auto flex gap-2">
{pending > 0 && <button className="btn btn-primary btn-sm" onClick={() => onReview(s)}>Review</button>}
{s.status === "submitted" && (
<button className="btn btn-sm" disabled={busy} title="Let the respondent edit and submit again"
onClick={() => run(() => sendJson(`${base}/${s.id}`, "PUT", { action: "reopen" }))}>Reopen</button>
)}
<button className="btn btn-ghost btn-sm btn-danger" disabled={busy}
onClick={() => { if (confirm("Delete this questionnaire? Its link stops working. Tools you already accepted stay in the assessment.")) run(() => sendJson(`${base}/${s.id}`, "DELETE")); }}>
Delete
</button>
</span>
</div>
</li>
);
})}
</ul>
)}
{creating ? (
<div className="rounded-xl border p-4">
<h3 className="mb-3 text-[13.5px] font-semibold">New questionnaire</h3>
<div className="mb-4 max-w-[340px]">
<label className="label" htmlFor="sv-name">Who is it for?</label>
<input id="sv-name" className="field" placeholder="e.g. Dana Lee, CIO" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="mb-2 flex items-center gap-2">
<span className="label mb-0">Categories to ask about · {picked.size} of {domains.length}</span>
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setPicked(new Set(domains.map((d) => d.id)))}>All</button>
<button className="btn btn-ghost btn-sm" onClick={() => setPicked(new Set())}>None</button>
</div>
<div className="scroll-thin grid max-h-[300px] gap-1 overflow-y-auto sm:grid-cols-2">
{domains.map((d) => (
<label key={d.id} className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-[var(--surface-2)]">
<input type="checkbox" className="mt-0.5" checked={picked.has(d.id)} onChange={() => toggle(d.id)} />
<span className="text-[12.5px]">{d.name}</span>
</label>
))}
</div>
<div className="mt-4 flex justify-end gap-2">
{surveys.length > 0 && <button className="btn" onClick={() => setCreating(false)}>Cancel</button>}
<button className="btn btn-primary" onClick={create} disabled={busy || picked.size === 0}>
{busy ? "Creating…" : "Create & copy link"}
</button>
</div>
</div>
) : (
<button className="btn" onClick={() => setCreating(true)}>+ New questionnaire</button>
)}
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useMemo, useState } from "react";
import type { Candidate, Catalog, KbEntry, KnowledgeBase, Survey } from "@/lib/types";
import { type PendingTool, candidateKey, pendingTools } from "@/lib/survey";
import { candidateLabel, splitVendor } from "@/lib/kb";
import { dateLabel, money } from "@/lib/format";
/** The knowledge-base entry a respondent's answer refers to, by id or by name. */
export function kbEntryFor(kb: KnowledgeBase | null, t: Pick<PendingTool, "kbEntryId" | "label">): KbEntry | null {
if (!kb) return null;
const byId = t.kbEntryId ? kb.entries.find((e) => e.id === t.kbEntryId) : undefined;
if (byId) return byId;
const l = t.label.toLowerCase();
return kb.entries.find((e) => e.label.toLowerCase() === l || e.aliases.some((a) => a.toLowerCase() === l)) ?? null;
}
export function toolVendorProduct(kb: KnowledgeBase | null, t: PendingTool) {
const e = kbEntryFor(kb, t);
return e ? { vendor: e.vendor, product: e.product } : splitVendor(t.label);
}
/** Review inbox for one submitted questionnaire. */
export default function SurveyReview({
survey, catalog, kb, candidates, onAccept, onClose,
}: {
survey: Survey;
catalog: Catalog;
kb: KnowledgeBase | null;
candidates: Candidate[];
onAccept: (accepted: PendingTool[], dismissed: PendingTool[]) => void;
onClose: () => void;
}) {
const tools = useMemo(() => pendingTools(survey), [survey]);
const [picked, setPicked] = useState<Set<string>>(() => new Set(tools.map((t) => t.key)));
const existing = useMemo(() => new Set(candidates.map((c) => candidateLabel(c).toLowerCase())), [candidates]);
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
const byDomain = survey.domains
.map((d) => ({ d, items: tools.filter((t) => t.domain === d) }))
.filter((g) => g.items.length);
const noneIn = survey.domains.filter((d) => survey.answers[d]?.status === "none");
const unsure = survey.domains.filter((d) => survey.answers[d]?.status === "unsure");
const toggle = (k: string) => setPicked((prev) => {
const next = new Set(prev);
next.has(k) ? next.delete(k) : next.add(k);
return next;
});
const accepted = tools.filter((t) => picked.has(t.key));
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Review questionnaire"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
<div className="flex items-center gap-3 border-b px-5 py-3.5">
<div>
<h2 className="text-[15px] font-semibold tracking-tight">Review questionnaire</h2>
<p className="text-[12px] text-[var(--text-muted)]">
{survey.respondentName || "Unnamed respondent"} · submitted {dateLabel(survey.submittedAt)}
</p>
</div>
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
</div>
<div className="p-5">
<div className="mb-2 flex items-center gap-2">
<h3 className="text-[13px] font-semibold">Tools named — {picked.size} of {tools.length} selected</h3>
<button className="btn btn-ghost btn-sm ml-auto"
onClick={() => setPicked(new Set(picked.size === tools.length ? [] : tools.map((t) => t.key)))}>
{picked.size === tools.length ? "Clear all" : "Select all"}
</button>
</div>
<div className="scroll-thin mb-4 max-h-[420px] space-y-3 overflow-y-auto">
{byDomain.map(({ d, items }) => (
<div key={d}>
<div className="mb-1 text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{domainName(d)}</div>
<ul className="space-y-1.5">
{items.map((t) => {
const entry = kbEntryFor(kb, t);
const vp = toolVendorProduct(kb, t);
const dup = existing.has(candidateKey(vp.vendor, vp.product));
const facts = [
t.annualSpend !== null ? `${money(t.annualSpend)}/yr` : "",
t.contractEnd ? `contract ends ${t.contractEnd}` : "",
].filter(Boolean).join(" · ");
return (
<li key={t.key}>
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 hover:bg-[var(--surface-2)]">
<input type="checkbox" className="mt-0.5" checked={picked.has(t.key)} onChange={() => toggle(t.key)} />
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-baseline gap-2">
<span className="text-[13px] font-medium">{t.label}</span>
{!entry && <span className="chip">Typed in</span>}
{dup && <span className="chip" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>Already in assessment</span>}
{facts && <span className="tabnum text-[12px] text-[var(--text-secondary)]">{facts}</span>}
</span>
<span className="mt-0.5 block text-[12px] text-[var(--text-secondary)]">
{dup
? "Fills in spend and contract date on the existing line where they are blank."
: `→ ${entry?.productIds.length ? entry.productIds.map(prodName).join(", ") : "No replacement mapped yet — set it on the line"}`}
</span>
</span>
</label>
</li>
);
})}
</ul>
</div>
))}
{tools.length === 0 && <p className="text-[13px] text-[var(--text-muted)]">No tools left to review.</p>}
</div>
{(noneIn.length > 0 || unsure.length > 0) && (
<div className="mb-4 space-y-2 rounded-lg bg-[var(--surface-2)] p-3 text-[12.5px]">
{noneIn.length > 0 && (
<div>
<span className="font-semibold">Nothing in place:</span>{" "}
<span className="text-[var(--text-secondary)]">{noneIn.map(domainName).join(", ")}. These are whitespace, not displacements.</span>
</div>
)}
{unsure.length > 0 && (
<div>
<span className="font-semibold">Not sure:</span>{" "}
<span className="text-[var(--text-secondary)]">{unsure.map(domainName).join(", ")}. Worth a follow-up question.</span>
</div>
)}
</div>
)}
<div className="flex items-center justify-end gap-2 border-t pt-4">
<span className="mr-auto text-[12px] text-[var(--text-muted)]">Unticked tools are dismissed and won&rsquo;t be offered again.</span>
<button className="btn" onClick={onClose}>Later</button>
<button className="btn btn-primary" disabled={tools.length === 0}
onClick={() => onAccept(accepted, tools.filter((t) => !picked.has(t.key)))}>
Add {accepted.length} tool{accepted.length === 1 ? "" : "s"} to assessment
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
type Mode = "light" | "dark" | "system";
export default function ThemeToggle() {
const [mode, setMode] = useState<Mode>("system");
useEffect(() => {
setMode(((localStorage.getItem("prt-theme") as Mode) || "system"));
}, []);
function apply(next: Mode) {
setMode(next);
if (next === "system") {
localStorage.removeItem("prt-theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("prt-theme", next);
document.documentElement.setAttribute("data-theme", next);
}
}
const order: Mode[] = ["system", "light", "dark"];
const icon = { system: "◐", light: "☀", dark: "☾" }[mode];
return (
<button
className="btn btn-ghost btn-sm"
title={`Theme: ${mode}`}
aria-label={`Theme: ${mode}. Click to change.`}
onClick={() => apply(order[(order.indexOf(mode) + 1) % order.length])}
>
<span className="text-[14px] leading-none">{icon}</span>
<span className="hidden sm:inline capitalize">{mode}</span>
</button>
);
}

370
src/components/charts.tsx Normal file
View File

@@ -0,0 +1,370 @@
"use client";
import { useId, useState } from "react";
import { money } from "@/lib/format";
export const SERIES = [
"var(--series-1)", "var(--series-2)", "var(--series-3)", "var(--series-4)",
"var(--series-5)", "var(--series-6)", "var(--series-7)", "var(--series-8)",
];
/* ---------------------------------------------------------------- frame */
export function ChartFrame({
title, subtitle, children, table, right,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
/** Every chart ships a table view: it is the relief for low-contrast hues. */
table?: { headers: string[]; rows: (string | number)[][] };
right?: React.ReactNode;
}) {
const [showTable, setShowTable] = useState(false);
return (
<section className="card avoid-break p-5">
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0">
<h3 className="text-[14px] font-semibold tracking-tight">{title}</h3>
{subtitle && <p className="mt-0.5 text-[12.5px] text-[var(--text-muted)]">{subtitle}</p>}
</div>
<div className="ml-auto flex items-center gap-1.5">
{right}
{table && (
<button className="btn btn-ghost btn-sm no-print" onClick={() => setShowTable((v) => !v)}>
{showTable ? "Chart" : "Table"}
</button>
)}
</div>
</div>
{showTable && table ? <DataTable {...table} /> : children}
</section>
);
}
export function DataTable({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
return (
<div className="scroll-thin -mx-1 overflow-x-auto px-1">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b">
{headers.map((h, i) => (
<th key={h} className={`pb-2 font-semibold text-[var(--text-secondary)] ${i ? "pl-4 text-right" : "text-left"}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-b border-[var(--grid)] last:border-0">
{r.map((c, j) => (
<td key={j} className={`py-1.5 tabnum ${j ? "pl-4 text-right text-[var(--text-secondary)]" : "font-medium"}`}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/* ------------------------------------------------------------ stat tile */
export function StatTile({
label, value, sub, tone = "neutral",
}: {
label: string;
value: string;
sub?: string;
tone?: "neutral" | "good" | "accent";
}) {
const color =
tone === "good" ? "var(--success-text)" : tone === "accent" ? "var(--accent)" : "var(--text-primary)";
return (
<div className="card avoid-break p-4">
<div className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
<div className="mt-1.5 text-[26px] font-semibold leading-none tracking-tight" style={{ color }}>{value}</div>
{sub && <div className="mt-1.5 text-[12px] text-[var(--text-secondary)]">{sub}</div>}
</div>
);
}
/* ------------------------------------------------------- horizontal bar */
export type BarDatum = { label: string; value: number; color?: string; note?: string };
export function HBar({ data, formatter = (n: number) => money(n) }: { data: BarDatum[]; formatter?: (n: number) => string }) {
const max = Math.max(1, ...data.map((d) => d.value));
if (!data.length) return <Empty />;
return (
<ul className="flex flex-col gap-3">
{data.map((d) => (
<li key={d.label} className="group">
<div className="mb-1 flex items-baseline gap-3">
<span className="truncate text-[12.5px] font-medium">{d.label}</span>
<span className="ml-auto shrink-0 tabnum text-[12.5px] font-semibold">{formatter(d.value)}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--grid)]">
<div
className="h-full rounded-full transition-[width] duration-500 ease-out"
style={{ width: `${Math.max(1.5, (d.value / max) * 100)}%`, background: d.color ?? "var(--series-1)" }}
title={`${d.label}: ${formatter(d.value)}${d.note ? ` — ${d.note}` : ""}`}
/>
</div>
{d.note && <div className="mt-1 text-[11.5px] text-[var(--text-muted)]">{d.note}</div>}
</li>
))}
</ul>
);
}
/* --------------------------------------------------------- quadrant map */
export type QuadPoint = {
id: string; x: number; y: number; size: number;
label: string; sublabel: string; colorIndex: number; groupLabel: string;
};
export function Quadrant({ points, groups }: { points: QuadPoint[]; groups: { label: string; colorIndex: number }[] }) {
const [hover, setHover] = useState<QuadPoint | null>(null);
const uid = useId();
const W = 640, H = 420, PAD = { t: 18, r: 18, b: 44, l: 52 };
const iw = W - PAD.l - PAD.r, ih = H - PAD.t - PAD.b;
// Axes are fixed 1–5 rating scales, so the domain never moves with the data.
const sx = (v: number) => PAD.l + ((v - 0.5) / 5) * iw;
const sy = (v: number) => PAD.t + ih - ((v - 0.5) / 5) * ih;
const maxSize = Math.max(1, ...points.map((p) => p.size));
const r = (v: number) => 7 + 20 * Math.sqrt(Math.max(0, v) / maxSize);
// Scores are integers 1-5, so candidates routinely land on the same point.
// Fan co-located bubbles out around their shared coordinate so none is hidden.
const placed = (() => {
const groups = new Map<string, QuadPoint[]>();
for (const p of points) {
const k = `${p.x}|${p.y}`;
(groups.get(k) ?? groups.set(k, []).get(k)!).push(p);
}
const out: (QuadPoint & { ox: number; oy: number })[] = [];
for (const group of groups.values()) {
if (group.length === 1) {
out.push({ ...group[0], ox: 0, oy: 0 });
continue;
}
// Largest bubble sits in the centre; the rest ring it, biggest first.
const sorted = [...group].sort((a, b) => b.size - a.size);
const spread = Math.max(...sorted.map((p) => r(p.size))) * 0.95;
sorted.forEach((p, i) => {
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
out.push({ ...p, ox: i === 0 ? 0 : Math.cos(angle) * spread, oy: i === 0 ? 0 : Math.sin(angle) * spread });
});
}
return out;
})();
if (!points.length) return <Empty />;
return (
<div className="relative">
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[680px]" role="img" aria-label="Capability parity versus switching ease">
<rect x={sx(3)} y={PAD.t} width={PAD.l + iw - sx(3)} height={sy(3) - PAD.t} fill="var(--accent)" opacity=".05" />
<text x={PAD.l + iw - 8} y={PAD.t + ih - 8} textAnchor="end" fontSize="10.5" fontWeight="600" fill="var(--text-muted)" letterSpacing=".05em">
ACT NOW
</text>
<text x={PAD.l + 8} y={PAD.t + ih - 8} fontSize="10.5" fontWeight="600" fill="var(--text-muted)" letterSpacing=".05em">
DEFER
</text>
{[1, 2, 3, 4, 5].map((v) => (
<g key={v}>
<line x1={sx(v)} y1={PAD.t} x2={sx(v)} y2={PAD.t + ih} stroke="var(--grid)" strokeWidth="1" />
<line x1={PAD.l} y1={sy(v)} x2={PAD.l + iw} y2={sy(v)} stroke="var(--grid)" strokeWidth="1" />
<text x={sx(v)} y={PAD.t + ih + 16} textAnchor="middle" fontSize="11" fill="var(--text-muted)" className="tabnum">{v}</text>
<text x={PAD.l - 10} y={sy(v) + 4} textAnchor="end" fontSize="11" fill="var(--text-muted)" className="tabnum">{v}</text>
</g>
))}
<line x1={PAD.l} y1={PAD.t + ih} x2={PAD.l + iw} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1.5" />
<line x1={PAD.l} y1={PAD.t} x2={PAD.l} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1.5" />
<text x={PAD.l + iw / 2} y={H - 8} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--text-secondary)">
Ease of switching →
</text>
<text x={14} y={PAD.t + ih / 2} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--text-secondary)"
transform={`rotate(-90 14 ${PAD.t + ih / 2})`}>
Capability parity →
</text>
{[...placed].sort((a, b) => b.size - a.size).map((p) => {
const on = hover?.id === p.id;
const cx = sx(p.x) + p.ox, cy = sy(p.y) + p.oy;
return (
<g key={p.id} onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} style={{ cursor: "pointer" }}>
{/* 2px surface ring keeps overlapping bubbles legible */}
<circle cx={cx} cy={cy} r={r(p.size) + 1.5} fill="none" stroke="var(--surface-1)" strokeWidth="2" opacity=".85" />
<circle
cx={cx} cy={cy} r={r(p.size)}
fill={SERIES[p.colorIndex]} fillOpacity={on ? 0.55 : 0.34}
stroke={SERIES[p.colorIndex]} strokeWidth={on ? 2.5 : 2}
/>
</g>
);
})}
{/*
The largest lines carry a visible label: hover is unavailable in print.
Only the lead bubble of each cluster is labelled, or co-located points
would stack their labels on top of one another.
*/}
{[...placed]
.filter((p) => p.ox === 0 && p.oy === 0)
.sort((a, b) => b.size - a.size)
.slice(0, 4)
.map((p) => (
<text
key={`lbl-${p.id}`}
x={sx(p.x) + p.ox}
y={sy(p.y) + p.oy - r(p.size) - 6}
textAnchor="middle"
fontSize="11"
fontWeight="600"
fill="var(--text-secondary)"
>
{p.label.length > 22 ? `${p.label.slice(0, 21)}…` : p.label}
</text>
))}
<desc id={uid}>Bubble area is annual spend at stake.</desc>
</svg>
{hover && (
<div className="pointer-events-none absolute left-1/2 top-2 z-10 w-[min(300px,90%)] -translate-x-1/2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-3 text-[12.5px] shadow-[var(--shadow)]">
<div className="flex items-center gap-2 font-semibold">
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: SERIES[hover.colorIndex] }} />
<span className="truncate">{hover.label}</span>
</div>
<div className="mt-1 text-[var(--text-secondary)]">{hover.sublabel}</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 tabnum text-[var(--text-muted)]">
<span>Parity {hover.y}/5</span>
<span>Switch ease {hover.x}/5</span>
<span>{money(hover.size, true)} at stake</span>
</div>
</div>
)}
<Legend groups={groups} note="Bubble size = annual spend at stake" />
</div>
);
}
export function Legend({ groups, note }: { groups: { label: string; colorIndex: number }[]; note?: string }) {
return (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-[var(--text-secondary)]">
{groups.map((g) => (
<span key={g.label} className="inline-flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: SERIES[g.colorIndex] }} />
{g.label}
</span>
))}
{note && <span className="ml-auto text-[var(--text-muted)]">{note}</span>}
</div>
);
}
/* ----------------------------------------------------- cumulative value */
export function PaybackCurve({
annualSavings, migrationCost, years,
}: { annualSavings: number; migrationCost: number; years: number }) {
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
const W = 640, H = 300, PAD = { t: 16, r: 16, b: 40, l: 72 };
const iw = W - PAD.l - PAD.r, ih = H - PAD.t - PAD.b;
const steps = Math.max(1, years) * 4; // quarterly resolution
const pts = Array.from({ length: steps + 1 }, (_, i) => {
const yr = (i / steps) * years;
return { yr, net: annualSavings * yr - migrationCost, invested: migrationCost };
});
const lo = Math.min(0, ...pts.map((p) => p.net));
const hi = Math.max(1, ...pts.map((p) => p.net), migrationCost);
const sx = (yr: number) => PAD.l + (yr / Math.max(years, 0.001)) * iw;
const sy = (v: number) => PAD.t + ih - ((v - lo) / (hi - lo || 1)) * ih;
const path = pts.map((p, i) => `${i ? "L" : "M"}${sx(p.yr).toFixed(1)},${sy(p.net).toFixed(1)}`).join(" ");
const area = `${path} L${sx(years)},${sy(0)} L${sx(0)},${sy(0)} Z`;
const breakYr = annualSavings > 0 ? migrationCost / annualSavings : null;
const h = hoverIdx === null ? null : pts[hoverIdx];
return (
<div className="relative">
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[680px]" role="img" aria-label="Cumulative net benefit over the planning horizon">
<defs>
<linearGradient id="pc-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--series-1)" stopOpacity=".28" />
<stop offset="100%" stopColor="var(--series-1)" stopOpacity="0" />
</linearGradient>
</defs>
{Array.from({ length: years + 1 }, (_, i) => (
<g key={i}>
<line x1={sx(i)} y1={PAD.t} x2={sx(i)} y2={PAD.t + ih} stroke="var(--grid)" strokeWidth="1" />
<text x={sx(i)} y={PAD.t + ih + 16} textAnchor="middle" fontSize="11" fill="var(--text-muted)">
{i === 0 ? "Start" : `Yr ${i}`}
</text>
</g>
))}
{[lo, (lo + hi) / 2, hi].map((v, i) => (
<g key={i}>
<line x1={PAD.l} y1={sy(v)} x2={PAD.l + iw} y2={sy(v)} stroke="var(--grid)" strokeWidth="1" />
<text x={PAD.l - 10} y={sy(v) + 4} textAnchor="end" fontSize="11" fill="var(--text-muted)" className="tabnum">{money(v, true)}</text>
</g>
))}
<line x1={PAD.l} y1={sy(0)} x2={PAD.l + iw} y2={sy(0)} stroke="var(--axis)" strokeWidth="1.5" />
<path d={area} fill="url(#pc-fill)" />
<path d={path} fill="none" stroke="var(--series-1)" strokeWidth="2" strokeLinecap="round" />
{breakYr !== null && breakYr <= years && (
<g>
<line x1={sx(breakYr)} y1={PAD.t} x2={sx(breakYr)} y2={PAD.t + ih} stroke="var(--success-text)" strokeWidth="1.5" strokeDasharray="4 3" />
<circle cx={sx(breakYr)} cy={sy(0)} r="4.5" fill="var(--success-text)" stroke="var(--surface-1)" strokeWidth="2" />
<text x={sx(breakYr) + 7} y={PAD.t + 14} fontSize="11" fontWeight="600" fill="var(--success-text)">
Breakeven {breakYr < 1 ? `${Math.round(breakYr * 12)} mo` : `${breakYr.toFixed(1)} yr`}
</text>
</g>
)}
<text x={sx(years) - 4} y={sy(pts[pts.length - 1].net) - 10} textAnchor="end" fontSize="12" fontWeight="600" fill="var(--text-primary)" className="tabnum">
{money(pts[pts.length - 1].net, true)}
</text>
{h && (
<g>
<line x1={sx(h.yr)} y1={PAD.t} x2={sx(h.yr)} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1" />
<circle cx={sx(h.yr)} cy={sy(h.net)} r="4.5" fill="var(--series-1)" stroke="var(--surface-1)" strokeWidth="2" />
</g>
)}
<rect
x={PAD.l} y={PAD.t} width={iw} height={ih} fill="transparent"
onMouseLeave={() => setHoverIdx(null)}
onMouseMove={(e) => {
const box = (e.target as SVGRectElement).getBoundingClientRect();
const frac = (e.clientX - box.left) / box.width;
setHoverIdx(Math.round(Math.min(1, Math.max(0, frac)) * steps));
}}
/>
</svg>
{h && (
<div className="pointer-events-none absolute right-4 top-3 rounded-lg border border-[var(--border-strong)] bg-[var(--surface-1)] px-3 py-2 text-[12.5px] shadow-[var(--shadow)]">
<div className="font-semibold tabnum">{h.yr < 1 ? `Month ${Math.round(h.yr * 12)}` : `Year ${h.yr.toFixed(2)}`}</div>
<div className="tabnum text-[var(--text-secondary)]">Net benefit {money(h.net)}</div>
</div>
)}
</div>
);
}
export function Empty({ children = "Nothing to show yet." }: { children?: React.ReactNode }) {
return (
<div className="flex min-h-[120px] items-center justify-center rounded-xl border border-dashed text-[13px] text-[var(--text-muted)]">
{children}
</div>
);
}