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:
130
src/components/AccountIntelStrip.tsx
Normal file
130
src/components/AccountIntelStrip.tsx
Normal 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
306
src/components/Analysis.tsx
Normal 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),
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
119
src/components/IncumbentPicker.tsx
Normal file
119
src/components/IncumbentPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/components/IntelImport.tsx
Normal file
198
src/components/IntelImport.tsx
Normal 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"} & 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>
|
||||
);
|
||||
}
|
||||
16
src/components/LoadError.tsx
Normal file
16
src/components/LoadError.tsx
Normal 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
26
src/components/Logo.tsx
Normal 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
56
src/components/Nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/ProductPicker.tsx
Normal file
97
src/components/ProductPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
src/components/SurveyPanel.tsx
Normal file
168
src/components/SurveyPanel.tsx
Normal 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’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’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>
|
||||
);
|
||||
}
|
||||
147
src/components/SurveyReview.tsx
Normal file
147
src/components/SurveyReview.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
39
src/components/ThemeToggle.tsx
Normal file
39
src/components/ThemeToggle.tsx
Normal 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
370
src/components/charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user