Sprint 2 UI and brand refresh
Workbench: ramp-adjusted headline (TCO, net run-rate, NPV, IRR, payback), financial model panel with internal-only investment, year-by-year cash flow, scenario panel (presets + custom, presented scenario, report comparison), overlap heat map, shelfware and expansion panel, editable implementation timeline (drag, resize, row editor, dependencies, reset), version history with compare and restore, deal status, customer logo. Deliverables: report, Word and new PowerPoint deck present the chosen scenario with net figures only; investment and dossier entitlements never itemized (covered by the leak suite). Exports and printing snapshot automatically. New pages: Portfolio dashboard, Settings (company name, logo, accent). Knowledge base: duplicate queue with merge, confidence and sourcing, TCO defaults, needs-review filter. Brand: VMware by Broadcom palette per Brand Guidelines.pdf; Darkmode navy dark theme; AA-safe accent ink; business units remapped to palette. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import { useRef, useState } from "react";
|
||||
import type { Assessment, Catalog, PlanOverride } from "@/lib/types";
|
||||
import type { Rollup } from "@/lib/scoring";
|
||||
import type { Evaluation } from "@/lib/finance";
|
||||
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";
|
||||
import { type Plan, type PlanItem, DEFAULT_PARALLEL, DURATION_BY_EASE, monthKey, monthLabel, quarterLabel } from "@/lib/timeline";
|
||||
|
||||
/* -------------------------------------------------------- tool footprint */
|
||||
|
||||
@@ -17,7 +18,7 @@ export function ToolFootprintTile({ r }: { r: Rollup }) {
|
||||
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` : ""}`}
|
||||
sub={`${cut > 0 ? `${cut} fewer tool${cut === 1 ? "" : "s"} (${pct}%)` : cut < 0 ? `${-cut} more products than tools` : "Same tool count"} · ${r.vendorsRetired} vendors → ${r.vendorsAfter}${r.unmappedCount ? ` · ${r.unmappedCount} unmapped` : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -91,18 +92,20 @@ function Num({ label, v, strong }: { label: string; v: string; strong?: boolean
|
||||
const PHASE_MONTHS = 6;
|
||||
|
||||
export function ImplementationTimeline({
|
||||
a, r, catalog, onParallel,
|
||||
e, catalog, onPlan, onParallel, onResetAll,
|
||||
}: {
|
||||
a: Assessment;
|
||||
r: Rollup;
|
||||
e: Evaluation;
|
||||
catalog: Catalog;
|
||||
/** Workbench only: lets the seller tune how many migrations run at once. */
|
||||
/** Workbench only: set (or clear, with null) a line's plan overrides. */
|
||||
onPlan?: (id: string, patch: PlanOverride | null) => void;
|
||||
onParallel?: (n: number) => void;
|
||||
onResetAll?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [asTable, setAsTable] = useState(false);
|
||||
const plan = useMemo(() => buildPlan(a, r, catalog), [a, r, catalog]);
|
||||
const { plan, a } = e;
|
||||
const L = (n: number, long = false) => monthLabel(plan.startIso, n, long);
|
||||
const edited = plan.items.filter((i) => i.edited).length;
|
||||
|
||||
const summary = plan.items.length
|
||||
? `${plan.items.length} projects · ${L(0)} – ${L(plan.completion - 1)} · full run-rate savings from ${L(plan.fullRunRate)}`
|
||||
@@ -117,11 +120,14 @@ export function ImplementationTimeline({
|
||||
<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>
|
||||
)}
|
||||
<span className="ml-auto flex shrink-0 gap-1.5">
|
||||
{plan.conflictCount > 0 && (
|
||||
<span className="chip" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{plan.conflictCount} dependency conflict{plan.conflictCount === 1 ? "" : "s"}</span>
|
||||
)}
|
||||
{plan.bridgeCount > 0 && (
|
||||
<span className="chip" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>{plan.bridgeCount} need a bridge extension</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Collapsed on screen, but always part of the printed report. */}
|
||||
@@ -130,9 +136,9 @@ export function ImplementationTimeline({
|
||||
<>
|
||||
<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="Full run-rate savings" value={L(plan.fullRunRate)} sub={`${money(e.cash.runRateNet, true)} net per year once complete`} />
|
||||
<Mini label={`Realized in ${a.horizonYears} years`} value={money(e.cash.horizonNet, true)}
|
||||
sub={`Net of one-time costs; NPV ${money(e.cash.npv, true)}`} />
|
||||
<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>
|
||||
@@ -144,21 +150,25 @@ export function ImplementationTimeline({
|
||||
<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>
|
||||
className={`px-2.5 py-1 text-[12px] font-semibold ${(a.planParallel ?? DEFAULT_PARALLEL) === n ? "bg-[var(--accent)] text-[var(--accent-ink)]" : "hover:bg-[var(--surface-2)]"}`}>{n}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{onPlan && <span className="text-[11.5px] text-[var(--text-muted)]">Drag a bar to move it, its right edge to resize, or click a project to edit.</span>}
|
||||
{onResetAll && edited > 0 && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onResetAll}>Reset {edited} edit{edited === 1 ? "" : "s"}</button>
|
||||
)}
|
||||
<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} />}
|
||||
{asTable ? <PlanTable plan={plan} /> : <Gantt plan={plan} catalog={catalog} onPlan={onPlan} />}
|
||||
|
||||
<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.
|
||||
flight per business unit. Migration length follows ease of switching ({Object.entries(DURATION_BY_EASE).reverse().map(([k, v]) => `${k}/5: ${v} mo`).join(", ")})
|
||||
unless set by hand. 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>
|
||||
</>
|
||||
@@ -178,9 +188,14 @@ function Mini({ label, value, sub }: { label: string; value: string; sub: string
|
||||
);
|
||||
}
|
||||
|
||||
function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
const span = plan.span;
|
||||
type Drag = { id: string; mode: "move" | "resize"; x0: number; perMonth: number; start: number; end: number; dStart: number; dEnd: number };
|
||||
|
||||
function Gantt({ plan, catalog, onPlan }: { plan: Plan; catalog: Catalog; onPlan?: (id: string, patch: PlanOverride | null) => void }) {
|
||||
const span = Math.max(plan.span, ...plan.items.map((i) => i.end + 1));
|
||||
const pct = (m: number) => `${(Math.min(Math.max(m, 0), span) / span) * 100}%`;
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const moved = useRef(false);
|
||||
|
||||
// Quarter ticks, labelled at each quarter boundary inside the span.
|
||||
const ticks: number[] = [];
|
||||
@@ -195,9 +210,37 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
|
||||
const buLegend = catalog.businessUnits.filter((b) => plan.items.some((i) => i.color === b.color));
|
||||
const peak = Math.max(1, ...plan.ramp);
|
||||
const labelOf = (id: string) => plan.items.find((i) => i.id === id)?.label ?? id;
|
||||
|
||||
const Track = ({ children, className = "" }: { children?: React.ReactNode; className?: string }) => (
|
||||
<div className={`relative ${className}`}>
|
||||
function begin(ev: React.PointerEvent, it: PlanItem, mode: Drag["mode"]) {
|
||||
if (!onPlan) return;
|
||||
ev.stopPropagation();
|
||||
const track = (ev.currentTarget as HTMLElement).closest("[data-track]") as HTMLElement;
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
moved.current = false;
|
||||
setDrag({ id: it.id, mode, x0: ev.clientX, perMonth: track.getBoundingClientRect().width / span, start: it.start, end: it.end, dStart: it.start, dEnd: it.end });
|
||||
}
|
||||
function move(ev: React.PointerEvent) {
|
||||
if (!drag) return;
|
||||
const d = Math.round((ev.clientX - drag.x0) / drag.perMonth);
|
||||
if (d !== 0) moved.current = true;
|
||||
setDrag(drag.mode === "move"
|
||||
? { ...drag, dStart: Math.max(0, drag.start + d), dEnd: Math.max(0, drag.start + d) + (drag.end - drag.start) }
|
||||
: { ...drag, dEnd: Math.max(drag.start + 1, drag.end + d) });
|
||||
}
|
||||
function end() {
|
||||
if (!drag || !onPlan) return;
|
||||
const it = plan.items.find((i) => i.id === drag.id);
|
||||
if (moved.current && it) {
|
||||
onPlan(drag.id, { start: monthKey(plan.startIso, drag.dStart), duration: drag.dEnd - drag.dStart, after: it.after.length ? it.after : undefined });
|
||||
}
|
||||
setDrag(null);
|
||||
}
|
||||
|
||||
// A render helper, not a component: a component defined here would be a new
|
||||
// type every render, remounting the bars and dropping pointer capture mid-drag.
|
||||
const track = (className: string, children: React.ReactNode) => (
|
||||
<div data-track 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>
|
||||
@@ -205,7 +248,7 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
|
||||
return (
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="min-w-[760px]" onPointerMove={move} onPointerUp={end} onPointerCancel={() => setDrag(null)}>
|
||||
{/* 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>
|
||||
@@ -228,38 +271,57 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
{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>}
|
||||
{items.map((i) => {
|
||||
const live = drag?.id === i.id ? { start: drag.dStart, end: drag.dEnd } : { start: i.start, end: i.end };
|
||||
return (
|
||||
<div key={i.id} className="border-b border-[var(--grid)]">
|
||||
<div className="grid grid-cols-[240px_minmax(0,1fr)]">
|
||||
<button className={`min-w-0 py-1.5 pr-3 text-left ${onPlan ? "hover:bg-[var(--surface-2)]" : "cursor-default"}`}
|
||||
disabled={!onPlan} onClick={() => setEditing(editing === i.id ? null : i.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[12.5px] font-medium">{i.label}</span>
|
||||
{i.edited && <span className="shrink-0 text-[10px] text-[var(--accent)]" title="Edited by hand">●</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>}
|
||||
{i.conflict && <span className="shrink-0 text-[11px]" style={{ color: "var(--critical)" }} title="Pinned before a project it depends on finishes">⛔</span>}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-[var(--text-muted)]">
|
||||
→ {i.products}{i.after.length ? ` · after ${i.after.map(labelOf).join(", ")}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
{track("h-full min-h-[38px]", <>
|
||||
{i.savingsFrom > live.end && (
|
||||
<div className="absolute top-1/2 h-0 -translate-y-1/2 border-t-2 border-dashed" style={{ left: pct(live.end), width: `calc(${pct(i.savingsFrom)} - ${pct(live.end)})`, borderColor: i.color, opacity: 0.6 }} />
|
||||
)}
|
||||
<div className={`absolute top-1/2 flex h-4 -translate-y-1/2 items-center rounded-[5px] ${onPlan ? "cursor-grab active:cursor-grabbing" : ""}`}
|
||||
onPointerDown={(ev) => begin(ev, i, "move")}
|
||||
title={`${i.label}: ${monthLabel(plan.startIso, live.start)} → cutover ${monthLabel(plan.startIso, live.end - 1)} · ${money(i.annualSavings)}/yr from ${monthLabel(plan.startIso, i.savingsFrom)}`}
|
||||
style={{
|
||||
left: pct(live.start), width: `calc(${pct(live.end)} - ${pct(live.start)})`, background: i.color, touchAction: "none",
|
||||
outline: i.conflict ? "2px solid var(--critical)" : drag?.id === i.id ? "2px solid var(--text-primary)" : undefined,
|
||||
...(i.bridge ? { backgroundImage: "repeating-linear-gradient(45deg, transparent 0 5px, rgba(255,255,255,.35) 5px 8px)" } : {}),
|
||||
}}>
|
||||
{onPlan && (
|
||||
<span className="ml-auto h-full w-2 cursor-ew-resize rounded-r-[5px] bg-black/20"
|
||||
onPointerDown={(ev) => begin(ev, i, "resize")} aria-label={`Resize ${i.label}`} />
|
||||
)}
|
||||
</div>
|
||||
{i.annualSavings > 0 && (
|
||||
<span className="pointer-events-none 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(live.end, i.savingsFrom)) }}>{money(i.annualSavings, true)}/yr</span>
|
||||
)}
|
||||
{i.contractEnd !== null && (
|
||||
<span className="pointer-events-none 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)}`} />
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-[var(--text-muted)]">→ {i.products}</div>
|
||||
{editing === i.id && onPlan && (
|
||||
<RowEditor it={i} plan={plan} onPlan={onPlan} onClose={() => setEditing(null)} />
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -269,13 +331,13 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
<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">
|
||||
{track("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`}
|
||||
d={`M0 100 ${plan.ramp.map((v, m) => `L${m} ${100 - (Math.max(0, v) / peak) * 92} L${m + 1} ${100 - (Math.max(0, v) / peak) * 92}`).join(" ")} L${span} ${100 - (Math.max(0, 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 */}
|
||||
@@ -286,19 +348,65 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
<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>
|
||||
{onPlan && <span className="flex items-center gap-1.5"><span className="text-[var(--accent)]">●</span>Edited by hand</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowEditor({ it, plan, onPlan, onClose }: { it: PlanItem; plan: Plan; onPlan: (id: string, patch: PlanOverride | null) => void; onClose: () => void }) {
|
||||
const months = Array.from({ length: 48 }, (_, n) => n);
|
||||
const others = plan.items.filter((x) => x.id !== it.id);
|
||||
const pinned = it.anchor === "pinned";
|
||||
const base = { start: pinned ? monthKey(plan.startIso, it.start) : undefined, duration: it.end - it.start, after: it.after.length ? it.after : undefined };
|
||||
const set = (p: Partial<PlanOverride>) => onPlan(it.id, { ...base, ...p });
|
||||
|
||||
return (
|
||||
<div className="fade-up no-print grid gap-3 bg-[var(--surface-2)] px-3 py-3 sm:grid-cols-[180px_120px_minmax(0,1fr)_auto]">
|
||||
<div>
|
||||
<label className="label" htmlFor={`pe-s-${it.id}`}>Start</label>
|
||||
<select id={`pe-s-${it.id}`} className="field" value={pinned ? String(it.start) : ""}
|
||||
onChange={(ev) => set({ start: ev.target.value === "" ? undefined : monthKey(plan.startIso, Number(ev.target.value)) })}>
|
||||
<option value="">Automatic ({monthLabel(plan.startIso, it.start)})</option>
|
||||
{months.map((n) => <option key={n} value={n}>{monthLabel(plan.startIso, n)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`pe-d-${it.id}`}>Months</label>
|
||||
<input id={`pe-d-${it.id}`} className="field tabnum" type="number" min={1} max={36} value={it.end - it.start}
|
||||
onChange={(ev) => set({ duration: Math.min(36, Math.max(1, Number(ev.target.value) || 1)) })} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="label">Starts after</span>
|
||||
<div className="scroll-thin flex max-h-[92px] flex-wrap gap-1 overflow-y-auto">
|
||||
{others.map((o) => {
|
||||
const on = it.after.includes(o.id);
|
||||
return (
|
||||
<button key={o.id} className="rounded-full border px-2 py-0.5 text-[11.5px]"
|
||||
style={on ? { background: "var(--accent)", borderColor: "var(--accent)", color: "var(--accent-ink)" } : { borderColor: "var(--border-strong)" }}
|
||||
onClick={() => set({ after: on ? it.after.filter((x) => x !== o.id) : [...it.after, o.id] })}>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-1.5">
|
||||
<button className="btn btn-sm" onClick={() => { onPlan(it.id, null); onClose(); }} disabled={!it.edited}>Reset row</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>Done</button>
|
||||
</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"]}
|
||||
headers={["Project", "Replacement", "Start", "Cutover", "Contract ends", "Savings from", "Net annual"]}
|
||||
rows={plan.items.map((i) => [
|
||||
`${i.label}${i.bridge ? " ⚠" : ""}`, i.products, L(i.start), L(i.end - 1),
|
||||
`${i.label}${i.bridge ? " ⚠" : ""}${i.edited ? " ●" : ""}`, i.products, L(i.start), L(i.end - 1),
|
||||
i.contractEnd === null ? "—" : L(i.contractEnd), L(i.savingsFrom), money(i.annualSavings),
|
||||
])}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
|
||||
import type { CandidateMetrics } from "@/lib/scoring";
|
||||
import { type CandidateMetrics, TCO_FALLBACK } from "@/lib/scoring";
|
||||
import { confidenceOf, findEntry } from "@/lib/kb";
|
||||
import { InternalBadge } from "./Finance";
|
||||
import { CAPABILITY_LABEL, SWITCH_LABEL, money, monthsLabel } from "@/lib/format";
|
||||
import ProductPicker, { suggest } from "./ProductPicker";
|
||||
import IncumbentPicker, { ConfidenceChip } from "./IncumbentPicker";
|
||||
@@ -53,6 +55,7 @@ export default function CandidateRow({
|
||||
migrationCost: e.migrationCost || c.migrationCost,
|
||||
unitLabel: e.unitLabel || c.unitLabel,
|
||||
risks: c.risks.trim() || e.risks,
|
||||
tcoDefaults: { ...(e.tco ?? TCO_FALLBACK) },
|
||||
});
|
||||
setApplied(e);
|
||||
}
|
||||
@@ -61,6 +64,13 @@ export default function CandidateRow({
|
||||
.filter((p) => !c.productIds.includes(p.id));
|
||||
const mapped = c.productIds.map((id) => catalog.products.find((p) => p.id === id)).filter(Boolean);
|
||||
const title = [c.incumbentVendor, c.incumbentProduct].filter(Boolean).join(" ") || "Untitled incumbent tool";
|
||||
const entry = findEntry(kb, title);
|
||||
const conf = entry ? confidenceOf(entry) : null;
|
||||
const d = c.tcoDefaults ?? TCO_FALLBACK;
|
||||
const setTco = (k: "infra" | "adminFte" | "other", v: number | null) =>
|
||||
onChange({ ...c, tco: { infra: c.tco?.infra ?? null, adminFte: c.tco?.adminFte ?? null, other: c.tco?.other ?? null, [k]: v } });
|
||||
const setInv = (k: "annual" | "services", v: number | null) =>
|
||||
onChange({ ...c, investment: { annual: c.investment?.annual ?? null, services: c.investment?.services ?? null, [k]: v } });
|
||||
|
||||
return (
|
||||
<div className={`card avoid-break overflow-hidden transition-opacity ${c.included ? "" : "opacity-55"}`}>
|
||||
@@ -71,6 +81,10 @@ export default function CandidateRow({
|
||||
<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>}
|
||||
{c.alreadyEntitled && (
|
||||
<span className="chip" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }}
|
||||
title={`The customer already owns unused ${c.alreadyEntitled} licences — no new subscription for this line`}>Already entitled</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-[var(--text-muted)]">
|
||||
{mapped.length ? `→ ${mapped.map((p) => p!.name).join(", ")}` : "No replacement mapped yet"}
|
||||
@@ -79,11 +93,11 @@ export default function CandidateRow({
|
||||
|
||||
<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 className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">TCO</div>
|
||||
<div className="tabnum text-[13px] font-semibold" title={`Licence ${money(m.licence)} · infra ${money(m.infra)} · admin ${money(m.admin)} · other ${money(m.other)}`}>{money(m.tco, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Saving</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Net / yr</div>
|
||||
<div className="tabnum text-[13px] font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -181,6 +195,33 @@ export default function CandidateRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Total cost and pricing ---- */}
|
||||
<div className="mt-5 border-t pt-4">
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-2">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Total cost of ownership</h4>
|
||||
<span className="tabnum text-[12px] text-[var(--text-secondary)]">
|
||||
{money(m.licence)} licence + {money(m.infra)} infra + {money(m.admin)} admin + {money(m.other)} other = <strong>{money(m.tco)}</strong>/yr
|
||||
</span>
|
||||
{conf && (
|
||||
<span className="ml-auto text-[11.5px] text-[var(--text-muted)]" title={conf.stale ? "Not reviewed in the last 12 months" : undefined}>
|
||||
Defaults: <span style={{ color: conf.level === "high" ? "var(--success-text)" : conf.level === "medium" ? "var(--accent)" : "var(--text-muted)" }}>{conf.level} confidence</span> · {conf.source}{conf.stale ? " · needs review" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<TcoField id={`ti-${c.id}`} label="Infrastructure (USD)" value={c.tco?.infra ?? null} fallback={Math.round(m.licence * d.infraPct / 100)}
|
||||
hint={`Default ${d.infraPct}% of licence`} step={1000} onChange={(v) => setTco("infra", v)} />
|
||||
<TcoField id={`ta-${c.id}`} label="Admin (FTEs)" value={c.tco?.adminFte ?? null} fallback={m.licence > 0 ? d.adminFte : 0}
|
||||
hint={`${money(m.admin, true)} at the assessment FTE cost`} step={0.05} onChange={(v) => setTco("adminFte", v)} />
|
||||
<TcoField id={`to-${c.id}`} label="Other run costs (USD)" value={c.tco?.other ?? null} fallback={Math.round(m.licence * d.otherPct / 100)}
|
||||
hint={`Default ${d.otherPct}% — training, integrations`} step={1000} onChange={(v) => setTco("other", v)} />
|
||||
<TcoField id={`ia-${c.id}`} label={<>Our price / yr <InternalBadge /></>} value={c.investment?.annual ?? null} fallback={Math.round(m.subscription)}
|
||||
hint={c.alreadyEntitled ? "Already entitled — none by default" : "Blank = share of the deal-level price"} step={1000} onChange={(v) => setInv("annual", v)} />
|
||||
<TcoField id={`is-${c.id}`} label={<>Our services <InternalBadge /></>} value={c.investment?.services ?? null} fallback={Math.round(m.services)}
|
||||
hint="Blank = share of deal-level services" step={1000} onChange={(v) => setInv("services", v)} />
|
||||
</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}
|
||||
@@ -214,7 +255,7 @@ export default function CandidateRow({
|
||||
</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
|
||||
{money(m.annualSavings)}/yr net · {monthsLabel(m.paybackMonths)} payback
|
||||
{m.roiPct !== null && ` · ${m.roiPct.toFixed(0)}% ROI`}
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={onDuplicate}>Duplicate</button>
|
||||
@@ -226,3 +267,25 @@ export default function CandidateRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A cost field that shows its default until the seller types an override. */
|
||||
function TcoField({
|
||||
id, label, value, fallback, hint, step, onChange,
|
||||
}: {
|
||||
id: string; label: React.ReactNode; value: number | null; fallback: number; hint: string; step: number;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={id}>{label}</label>
|
||||
<div className="flex gap-1">
|
||||
<input id={id} className="field tabnum" type="number" min={0} step={step} placeholder={String(fallback)}
|
||||
value={value ?? ""} onChange={(e) => onChange(e.target.value === "" ? null : Math.max(0, Number(e.target.value)))} />
|
||||
{value !== null && (
|
||||
<button className="btn btn-ghost btn-sm shrink-0" title="Use the default" onClick={() => onChange(null)}>↺</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{value === null ? `Default · ${hint}` : hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
353
src/components/Finance.tsx
Normal file
353
src/components/Finance.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog, Scenario } from "@/lib/types";
|
||||
import { financeOf } from "@/lib/scoring";
|
||||
import { type Evaluation, EXPECTED_ID, allScenarios, evaluate } from "@/lib/finance";
|
||||
import { DEFAULT_PARALLEL, monthLabel } from "@/lib/timeline";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
import { StatTile } from "@/components/charts";
|
||||
import { ToolFootprintTile } from "@/components/Analysis";
|
||||
|
||||
const pct = (n: number) => `${Math.round(n)}%`;
|
||||
const paybackText = (m: number | null) => (m === null ? "Beyond horizon" : m === 0 ? "Immediate" : monthsLabel(m));
|
||||
|
||||
/* ------------------------------------------------------------ headline */
|
||||
|
||||
/** Ramp-adjusted headline: the numbers the plan actually delivers. */
|
||||
export function HeadlineTiles({ e, internal }: { e: Evaluation; internal?: boolean }) {
|
||||
const { r, cash, a } = e;
|
||||
const f = financeOf(a);
|
||||
const flat = r.totalAnnualSavings * a.horizonYears - r.totalMigrationCost;
|
||||
return (
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatTile label="Current cost (TCO)" value={money(r.addressableTco)}
|
||||
sub={`${money(r.addressableSpend, true)} licence · ${r.toolsRetired} tools · ${r.vendorsRetired} vendors`} />
|
||||
<StatTile label="Net annual benefit" value={money(cash.runRateNet)} tone="good"
|
||||
sub={`At full run-rate · ${pct(r.addressableTco ? (cash.runRateNet / r.addressableTco) * 100 : 0)} of TCO`} />
|
||||
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(cash.horizonNet)} tone="accent"
|
||||
sub={`NPV ${money(cash.npv, true)} at ${f.discountRate}%${internal ? ` · flat model ${money(flat, true)}` : ""}`} />
|
||||
<StatTile label="Payback" value={paybackText(cash.paybackMonth)}
|
||||
sub={cash.irr === null ? `${money(cash.oneTimeTotal, true)} one-time investment` : `IRR ${Math.round(cash.irr)}% · ${money(cash.oneTimeTotal, true)} one-time`} />
|
||||
<ToolFootprintTile r={r} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ cash flow */
|
||||
|
||||
export function CashFlowTable({ e, showSubscription }: { e: Evaluation; showSubscription: boolean }) {
|
||||
const { cash } = e;
|
||||
const cols = showSubscription
|
||||
? ["Cost removed", "Our subscription", "One-time", "Net", "Cumulative"]
|
||||
: ["Recurring change", "One-time", "Net", "Cumulative"];
|
||||
return (
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="pb-2 text-left font-semibold text-[var(--text-secondary)]" />
|
||||
{cols.map((h) => <th key={h} className="pb-2 pl-4 text-right font-semibold text-[var(--text-secondary)]">{h}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cash.years.map((y) => (
|
||||
<tr key={y.year} className="border-b border-[var(--grid)] last:border-0">
|
||||
<td className="py-1.5 font-medium">
|
||||
Year {y.year}
|
||||
<span className="ml-1.5 text-[11px] font-normal text-[var(--text-muted)]">
|
||||
{monthLabel(e.plan.startIso, (y.year - 1) * 12)} – {monthLabel(e.plan.startIso, y.year * 12 - 1)}
|
||||
</span>
|
||||
</td>
|
||||
{showSubscription ? (
|
||||
<>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(y.removed)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">−{money(y.subscription)}</td>
|
||||
</>
|
||||
) : (
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(y.removed - y.subscription)}</td>
|
||||
)}
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{y.oneTime ? `−${money(y.oneTime)}` : "—"}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum font-semibold" style={{ color: y.net >= 0 ? "var(--success-text)" : "var(--critical)" }}>{money(y.net)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(y.cumulative)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ assumptions */
|
||||
|
||||
export function FinancePanel({ a, e, update }: { a: Assessment; e: Evaluation; update: (fn: (d: Assessment) => void) => void }) {
|
||||
const f = financeOf(a);
|
||||
const setF = (fn: (x: ReturnType<typeof financeOf>) => void) => update((d) => { const x = financeOf(d); fn(x); d.finance = x; });
|
||||
const num = (v: string) => Math.max(0, Number(v) || 0);
|
||||
|
||||
return (
|
||||
<section className="card p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Financial model</h3>
|
||||
<span className="ml-auto text-[11.5px] text-[var(--text-muted)]">
|
||||
{e.r.hasInvestment ? "Gross cost removed − our investment" : "Savings % of TCO — add our investment to switch to gross − investment"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="label" htmlFor="f-dr">Discount rate (%)</label>
|
||||
<input id="f-dr" className="field tabnum" type="number" min={0} max={50} step={0.5} value={f.discountRate}
|
||||
onChange={(ev) => setF((x) => { x.discountRate = Math.min(50, num(ev.target.value)); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="f-fte">Admin FTE cost (USD / yr)</label>
|
||||
<input id="f-fte" className="field tabnum" type="number" min={0} step={5000} value={f.fteCost}
|
||||
onChange={(ev) => setF((x) => { x.fteCost = num(ev.target.value); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="f-sub">Our subscription (USD / yr) <InternalBadge /></label>
|
||||
<input id="f-sub" className="field tabnum" type="number" min={0} step={10000} value={f.investment.annual}
|
||||
onChange={(ev) => setF((x) => { x.investment.annual = num(ev.target.value); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="f-svc">Services, one-time (USD) <InternalBadge /></label>
|
||||
<input id="f-svc" className="field tabnum" type="number" min={0} step={10000} value={f.investment.services}
|
||||
onChange={(ev) => setF((x) => { x.investment.services = num(ev.target.value); })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<CashFlowTable e={e} showSubscription={e.r.hasInvestment} />
|
||||
<p className="mt-2 text-[11.5px] text-[var(--text-muted)]">
|
||||
Ramp-adjusted from the implementation plan: savings start at each cutover, our subscription is billed from month 1,
|
||||
migration lands at each project start. The customer report shows net figures only — our investment is never itemized.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InternalBadge() {
|
||||
return (
|
||||
<span className="ml-1 rounded px-1 py-px align-middle text-[9.5px] font-bold tracking-[.06em]"
|
||||
style={{ background: "color-mix(in oklab, var(--warning) 22%, transparent)", color: "var(--text-primary)" }}
|
||||
title="Never shown on the customer report, Word file or deck">INTERNAL</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ scenarios */
|
||||
|
||||
export function ScenarioPanel({
|
||||
a, catalog, update,
|
||||
}: {
|
||||
a: Assessment; catalog: Catalog; update: (fn: (d: Assessment) => void) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const evals = useMemo(() => allScenarios(a).map((s) => evaluate(a, catalog, s)), [a, catalog]);
|
||||
const presented = a.presentedScenario ?? EXPECTED_ID;
|
||||
|
||||
function saveCurrent() {
|
||||
const id = `s-${Date.now().toString(36)}`;
|
||||
update((d) => {
|
||||
d.scenarios = [...(d.scenarios ?? []), {
|
||||
id, name: `Scenario ${(d.scenarios?.length ?? 0) + 1}`, kind: "custom", valueFactor: 1,
|
||||
excluded: d.candidates.filter((c) => !c.included).map((c) => c.id),
|
||||
parallel: d.planParallel ?? DEFAULT_PARALLEL,
|
||||
}];
|
||||
});
|
||||
setEditing(id);
|
||||
}
|
||||
|
||||
const setS = (id: string, fn: (s: Scenario) => void) =>
|
||||
update((d) => { const s = d.scenarios?.find((x) => x.id === id); if (s) fn(s); });
|
||||
|
||||
return (
|
||||
<section className="card p-5">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Scenarios</h3>
|
||||
<label className="ml-auto flex items-center gap-2 text-[12px] text-[var(--text-secondary)]">
|
||||
<input type="checkbox" checked={!!a.showScenarioComparison}
|
||||
onChange={(ev) => update((d) => { d.showScenarioComparison = ev.target.checked; })} />
|
||||
Show comparison in customer report
|
||||
</label>
|
||||
<button className="btn btn-sm" onClick={saveCurrent} title="Captures the current scope, pace and investment">+ Save current as scenario</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12px] text-[var(--text-muted)]">
|
||||
Presets derive from the assessment as it stands. The <strong>Presented</strong> scenario drives the report, Word file and deck.
|
||||
</p>
|
||||
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-[var(--text-secondary)]">
|
||||
{["Presented", "Scenario", "Tools", "Value", "Pace", "Net / yr", `${a.horizonYears}-yr net`, "NPV", "Payback", "Complete", ""].map((h, i) => (
|
||||
<th key={i} className={`pb-2 font-semibold ${i >= 5 && i <= 9 ? "pl-3 text-right" : "pl-3 text-left first:pl-0"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evals.map((ev) => {
|
||||
const s = ev.scenario;
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-[var(--grid)] last:border-0">
|
||||
<td className="py-2">
|
||||
<input type="radio" name="presented" checked={presented === s.id} aria-label={`Present ${s.name}`}
|
||||
onChange={() => update((d) => { d.presentedScenario = s.id; })} />
|
||||
</td>
|
||||
<td className="py-2 pl-3 font-medium">
|
||||
{s.name}{s.kind === "preset" && <span className="ml-1.5 text-[11px] font-normal text-[var(--text-muted)]">preset</span>}
|
||||
</td>
|
||||
<td className="py-2 pl-3 tabnum">{ev.r.toolsRetired}</td>
|
||||
<td className="py-2 pl-3 tabnum">{Math.round(s.valueFactor * 100)}%</td>
|
||||
<td className="py-2 pl-3 tabnum">{ev.a.planParallel ?? DEFAULT_PARALLEL}/BU</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{money(ev.cash.runRateNet, true)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum font-semibold">{money(ev.cash.horizonNet, true)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{money(ev.cash.npv, true)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{paybackText(ev.cash.paybackMonth)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{monthLabel(ev.plan.startIso, Math.max(0, ev.plan.completion - 1))}</td>
|
||||
<td className="py-2 pl-3 text-right">
|
||||
{s.kind === "custom" && (
|
||||
<span className="flex justify-end gap-1">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(editing === s.id ? null : s.id)}>Edit</button>
|
||||
<button className="btn btn-ghost btn-sm btn-danger" onClick={() => update((d) => {
|
||||
d.scenarios = (d.scenarios ?? []).filter((x) => x.id !== s.id);
|
||||
if (d.presentedScenario === s.id) d.presentedScenario = EXPECTED_ID;
|
||||
})}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && a.scenarios?.find((s) => s.id === editing) && (
|
||||
<ScenarioEditor a={a} s={a.scenarios.find((s) => s.id === editing)!} setS={(fn) => setS(editing, fn)} onDone={() => setEditing(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioEditor({ a, s, setS, onDone }: { a: Assessment; s: Scenario; setS: (fn: (s: Scenario) => void) => void; onDone: () => void }) {
|
||||
const excluded = new Set(s.excluded ?? []);
|
||||
const inv = s.investment;
|
||||
return (
|
||||
<div className="fade-up mt-3 rounded-xl border p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="label" htmlFor="sc-name">Name</label>
|
||||
<input id="sc-name" className="field" value={s.name} onChange={(ev) => setS((x) => { x.name = ev.target.value; })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="sc-vf">Value (% of modelled)</label>
|
||||
<input id="sc-vf" className="field tabnum" type="number" min={10} max={200} step={5} value={Math.round(s.valueFactor * 100)}
|
||||
onChange={(ev) => setS((x) => { x.valueFactor = Math.min(2, Math.max(0.1, Number(ev.target.value) / 100)); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="sc-p">Parallel per BU</label>
|
||||
<select id="sc-p" className="field" value={s.parallel ?? DEFAULT_PARALLEL} onChange={(ev) => setS((x) => { x.parallel = Number(ev.target.value); })}>
|
||||
{[1, 2, 3, 4].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="sc-inv">Subscription override <InternalBadge /></label>
|
||||
<input id="sc-inv" className="field tabnum" type="number" min={0} step={10000} placeholder="Same as base"
|
||||
value={inv?.annual ?? ""}
|
||||
onChange={(ev) => setS((x) => {
|
||||
x.investment = ev.target.value === "" ? undefined : { annual: Math.max(0, Number(ev.target.value)), services: x.investment?.services ?? financeOf(a).investment.services };
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="label">Tools in scope · {a.candidates.length - excluded.size} of {a.candidates.length}</div>
|
||||
<div className="scroll-thin grid max-h-[180px] gap-0.5 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
{a.candidates.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 rounded px-1.5 py-1 text-[12px] hover:bg-[var(--surface-2)]">
|
||||
<input type="checkbox" checked={!excluded.has(c.id)}
|
||||
onChange={(ev) => setS((x) => {
|
||||
const set = new Set(x.excluded ?? []);
|
||||
ev.target.checked ? set.delete(c.id) : set.add(c.id);
|
||||
x.excluded = [...set];
|
||||
})} />
|
||||
<span className="truncate">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled"}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end"><button className="btn btn-sm" onClick={onDone}>Done</button></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only comparison for customer deliverables — net figures only. */
|
||||
export function ScenarioComparison({ a, catalog }: { a: Assessment; catalog: Catalog }) {
|
||||
const evals = useMemo(() => allScenarios(a).map((s) => evaluate(a, catalog, s)), [a, catalog]);
|
||||
const presented = a.presentedScenario ?? EXPECTED_ID;
|
||||
return (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-[var(--text-secondary)]">
|
||||
{["Scenario", "Tools", "Net annual benefit", `${a.horizonYears}-year net`, "NPV", "Payback", "Complete"].map((h, i) => (
|
||||
<th key={h} className={`pb-2 font-semibold ${i ? "pl-4 text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evals.map((ev) => (
|
||||
<tr key={ev.scenario.id} className="border-b border-[var(--grid)] last:border-0" style={ev.scenario.id === presented ? { fontWeight: 600 } : undefined}>
|
||||
<td className="py-1.5">{ev.scenario.name}{ev.scenario.id === presented && " (presented)"}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{ev.r.toolsRetired}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(ev.cash.runRateNet)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(ev.cash.horizonNet)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(ev.cash.npv)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{paybackText(ev.cash.paybackMonth)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{monthLabel(ev.plan.startIso, Math.max(0, ev.plan.completion - 1))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ cumulative chart */
|
||||
|
||||
/** Cumulative net position month by month, with the breakeven point marked. */
|
||||
export function CumulativeChart({ e }: { e: Evaluation }) {
|
||||
const months = e.cash.months;
|
||||
const cum: number[] = [];
|
||||
months.reduce((s, v, i) => (cum[i] = s + v), 0);
|
||||
const W = 640, H = 220, P = { l: 56, r: 12, t: 12, b: 26 };
|
||||
const lo = Math.min(0, ...cum), hi = Math.max(1, ...cum);
|
||||
const x = (i: number) => P.l + ((W - P.l - P.r) * (i + 1)) / Math.max(1, months.length);
|
||||
const y = (v: number) => P.t + ((H - P.t - P.b) * (hi - v)) / (hi - lo || 1);
|
||||
const path = `M${P.l} ${y(0)} ` + cum.map((v, i) => `L${x(i)} ${y(v)}`).join(" ");
|
||||
const pb = e.cash.paybackMonth;
|
||||
const ticks = [lo, 0, hi].filter((v, i, arr) => arr.indexOf(v) === i);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img"
|
||||
aria-label={`Cumulative net position reaches ${money(cum[cum.length - 1] ?? 0)} after ${e.a.horizonYears} years`}>
|
||||
{ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={P.l} x2={W - P.r} y1={y(v)} y2={y(v)} stroke={v === 0 ? "var(--axis)" : "var(--grid)"} />
|
||||
<text x={P.l - 6} y={y(v) + 4} textAnchor="end" fontSize="10.5" fill="var(--text-muted)">{money(v, true)}</text>
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: e.a.horizonYears }, (_, k) => (
|
||||
<text key={k} x={x((k + 1) * 12 - 1)} y={H - 8} textAnchor="end" fontSize="10.5" fill="var(--text-muted)">Year {k + 1}</text>
|
||||
))}
|
||||
<path d={`${path} L${x(cum.length - 1)} ${y(0)} Z`} fill="var(--accent)" fillOpacity=".12" />
|
||||
<path d={path} fill="none" stroke="var(--accent)" strokeWidth="2" />
|
||||
{pb !== null && pb > 0 && pb <= months.length && (
|
||||
<g>
|
||||
<line x1={x(pb - 1)} x2={x(pb - 1)} y1={P.t} y2={H - P.b} stroke="var(--success-text)" strokeDasharray="3 3" />
|
||||
<text x={x(pb - 1) + 4} y={P.t + 10} fontSize="10.5" fill="var(--success-text)">Breakeven month {pb}</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ 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
|
||||
* savings. Brand Navy → Azure gradient at 45°, per the brand guide. 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 }) {
|
||||
@@ -11,8 +12,8 @@ export default function Logo({ size = 24 }: { size?: number }) {
|
||||
<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" />
|
||||
<stop stopColor="#005C8A" />
|
||||
<stop offset="1" stopColor="#0098C7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill={`url(#${id})`} />
|
||||
|
||||
37
src/components/LogoInput.tsx
Normal file
37
src/components/LogoInput.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { MAX_LOGO_BYTES } from "@/lib/images";
|
||||
|
||||
/** Picks a PNG or JPEG and hands it back as a data URL; exports embed it as-is. */
|
||||
export function LogoInput({ label, value, onChange }: { label: string; value: string; onChange: (dataUrl: string) => void }) {
|
||||
const id = useId();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
function pick(file: File | undefined) {
|
||||
setErr("");
|
||||
if (!file) return;
|
||||
if (!["image/png", "image/jpeg"].includes(file.type)) { setErr("Use a PNG or JPEG file."); return; }
|
||||
if (file.size > MAX_LOGO_BYTES) { setErr("That file is over 600 KB — export a smaller version."); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(String(reader.result));
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={id}>{label}</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={value} alt="" className="h-10 max-w-[160px] rounded border bg-white object-contain p-1" />
|
||||
) : (
|
||||
<span className="grid h-10 w-24 place-items-center rounded border border-dashed text-[11px] text-[var(--text-muted)]">No logo</span>
|
||||
)}
|
||||
<input id={id} type="file" accept="image/png,image/jpeg" className="text-[12px]" onChange={(e) => { pick(e.target.files?.[0]); e.target.value = ""; }} />
|
||||
{value && <button className="btn btn-ghost btn-sm" onClick={() => onChange("")}>Remove</button>}
|
||||
</div>
|
||||
{err && <p className="mt-1 text-[12px]" style={{ color: "var(--critical)" }}>{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import ThemeToggle from "./ThemeToggle";
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Assessments" },
|
||||
{ href: "/knowledge-base", label: "Knowledge base" },
|
||||
{ href: "/portfolio", label: "Portfolio" },
|
||||
{ href: "/catalog", label: "Product catalog" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
|
||||
80
src/components/Overlap.tsx
Normal file
80
src/components/Overlap.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Catalog } from "@/lib/types";
|
||||
import type { Rollup } from "@/lib/scoring";
|
||||
import { money } from "@/lib/format";
|
||||
import { ChartFrame, Empty } from "@/components/charts";
|
||||
|
||||
/**
|
||||
* Where the customer pays for the same capability more than once. Rows are
|
||||
* capability categories, shaded by how many incumbent tools overlap in each.
|
||||
*/
|
||||
export function OverlapMap({ r, catalog }: { r: Rollup; catalog: Catalog }) {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const rows = [...r.byDomain].sort((x, y) => y.count - x.count || y.spend - x.spend);
|
||||
const maxSpend = Math.max(1, ...rows.map((d) => d.spend));
|
||||
const redundant = rows.filter((d) => d.count > 1);
|
||||
const tone = (n: number) => (n >= 4 ? 0.9 : n === 3 ? 0.65 : n === 2 ? 0.4 : 0.12);
|
||||
|
||||
return (
|
||||
<ChartFrame
|
||||
title="Overlap heat map"
|
||||
subtitle={redundant.length
|
||||
? `${redundant.length} categor${redundant.length === 1 ? "y runs" : "ies run"} more than one tool — ${money(redundant.reduce((s, d) => s + d.spend, 0), true)} of cost where the customer pays twice.`
|
||||
: "No category runs more than one incumbent tool."}
|
||||
table={{
|
||||
headers: ["Capability", "Tools", "Vendors", "Current cost", "Net annual benefit"],
|
||||
rows: rows.map((d) => [d.name, d.count, d.vendors, money(d.spend), money(d.savings)]),
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? <Empty>Add incumbent tools to see overlap.</Empty> : (
|
||||
<ul className="divide-y divide-[var(--grid)]">
|
||||
{rows.map((d) => {
|
||||
const tools = r.included.filter((c) => c.domain === d.domainId);
|
||||
const isOpen = open === d.domainId;
|
||||
return (
|
||||
<li key={d.domainId}>
|
||||
<button className="grid w-full grid-cols-[minmax(0,1fr)_64px_minmax(0,1.2fr)_88px] items-center gap-3 py-2 text-left hover:bg-[var(--surface-2)]"
|
||||
aria-expanded={isOpen} onClick={() => setOpen(isOpen ? null : d.domainId)}>
|
||||
<span className="truncate pl-1 text-[12.5px] font-medium">{d.name}</span>
|
||||
<span className="rounded-md py-1 text-center tabnum text-[12.5px] font-semibold"
|
||||
style={{
|
||||
background: `color-mix(in oklab, var(--accent) ${Math.round(tone(d.count) * 100)}%, transparent)`,
|
||||
color: d.count >= 3 ? "var(--accent-ink)" : "var(--text-primary)",
|
||||
}}
|
||||
title={`${d.count} tools from ${d.vendors} vendor${d.vendors === 1 ? "" : "s"}`}>
|
||||
{d.count}
|
||||
</span>
|
||||
<span className="h-2 overflow-hidden rounded-full bg-[var(--grid)]">
|
||||
<span className="block h-full rounded-full bg-[var(--series-1)]" style={{ width: `${Math.max(1.5, (d.spend / maxSpend) * 100)}%` }} />
|
||||
</span>
|
||||
<span className="pr-1 text-right tabnum text-[12.5px]">{money(d.spend, true)}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className="fade-up mb-2 ml-1 flex flex-wrap gap-1.5">
|
||||
{tools.map((c) => (
|
||||
<li key={c.id} className="chip">
|
||||
{`${c.incumbentVendor} ${c.incumbentProduct}`.trim()} · {money(r.metrics.get(c.id)!.tco, true)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-3 text-[11px] text-[var(--text-muted)]">
|
||||
<span>Tools per category</span>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<span key={n} className="flex items-center gap-1">
|
||||
<span className="h-3 w-4 rounded-sm" style={{ background: `color-mix(in oklab, var(--accent) ${Math.round(tone(n) * 100)}%, transparent)` }} />
|
||||
{n === 4 ? "4+" : n}
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto">Bar = current cost</span>
|
||||
</div>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
64
src/components/Shelfware.tsx
Normal file
64
src/components/Shelfware.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import { shelfware } from "@/lib/shelfware";
|
||||
import { num } from "@/lib/format";
|
||||
import { InternalBadge } from "@/components/Finance";
|
||||
|
||||
/**
|
||||
* Entitled-but-undeployed products from the dossier. Linked ones already
|
||||
* cover a displacement, so that line needs no new subscription; the rest are
|
||||
* adoption upside. Internal: the dossier never reaches the customer.
|
||||
*/
|
||||
export function ShelfwarePanel({ a, catalog }: { a: Assessment; catalog: Catalog }) {
|
||||
const items = shelfware(a, catalog);
|
||||
if (!items.length) return null;
|
||||
const label = (id: string) => {
|
||||
const c = a.candidates.find((x) => x.id === id);
|
||||
return c ? `${c.incumbentVendor} ${c.incumbentProduct}`.trim() : id;
|
||||
};
|
||||
const linked = items.filter((s) => s.linked.length);
|
||||
const upside = items.filter((s) => !s.linked.length);
|
||||
|
||||
return (
|
||||
<section className="card mb-5 p-5">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Shelfware and expansion</h3>
|
||||
<InternalBadge />
|
||||
</div>
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
From the account dossier: products the customer already owns but has not fully deployed. Displacements that land on
|
||||
one of these need no new subscription.
|
||||
</p>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="label">Already entitled — covers {linked.reduce((s, x) => s + x.linked.length, 0)} displacement{linked.reduce((s, x) => s + x.linked.length, 0) === 1 ? "" : "s"}</div>
|
||||
{linked.length === 0 ? <p className="text-[12.5px] text-[var(--text-muted)]">None of the unused entitlements match a mapped replacement.</p> : (
|
||||
<ul className="space-y-1.5">
|
||||
{linked.map((s) => (
|
||||
<li key={s.product} className="rounded-lg border px-3 py-2">
|
||||
<div className="flex items-baseline gap-2 text-[12.5px]">
|
||||
<span className="font-medium">{s.match!.name}</span>
|
||||
<span className="ml-auto tabnum text-[var(--text-muted)]">{num(s.unused)} unused of {num(s.entitled)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-secondary)]">Replaces {s.linked.map(label).join(", ")}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Adoption upside — owned, not yet in the plan</div>
|
||||
<ul className="space-y-1">
|
||||
{upside.map((s) => (
|
||||
<li key={s.product} className="flex items-baseline gap-2 rounded-lg bg-[var(--surface-2)] px-3 py-1.5 text-[12.5px]">
|
||||
<span className="font-medium">{s.match?.name ?? s.product}</span>
|
||||
<span className="ml-auto tabnum text-[var(--text-muted)]">{num(s.unused)} unused of {num(s.entitled)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
162
src/components/Snapshots.tsx
Normal file
162
src/components/Snapshots.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog, Snapshot, SnapshotMeta } from "@/lib/types";
|
||||
import { evaluate, findScenario } from "@/lib/finance";
|
||||
import { candidateLabel } from "@/lib/kb";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
const TRIGGER: Record<SnapshotMeta["trigger"], string> = {
|
||||
manual: "Saved", word: "Word export", deck: "Deck export", print: "Printed", restore: "Before restore",
|
||||
};
|
||||
|
||||
/** Version history: named snapshots, automatic "as presented" copies, compare and restore. */
|
||||
export default function SnapshotPanel({
|
||||
a, catalog, onRestored, onClose,
|
||||
}: {
|
||||
a: Assessment; catalog: Catalog; onRestored: (next: Assessment) => void; onClose: () => void;
|
||||
}) {
|
||||
const base = `/api/assessments/${a.id}/snapshots`;
|
||||
const [list, setList] = useState<SnapshotMeta[] | null>(null);
|
||||
const [label, setLabel] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [compare, setCompare] = useState<Snapshot | null>(null);
|
||||
|
||||
const load = () => getJson<SnapshotMeta[]>(base).then(setList).catch((e) => setErr(errorMessage(e)));
|
||||
// Loads once per assessment; `base` changes only with the id.
|
||||
useEffect(() => { load(); }, [base]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setErr("");
|
||||
try { await sendJson(base, "POST", { label: label || `Snapshot ${new Date().toLocaleDateString()}` }); setLabel(""); await load(); }
|
||||
catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function restore(s: SnapshotMeta) {
|
||||
if (!confirm(`Restore “${s.label}”? The current version is saved as a snapshot first, so this can be undone.`)) return;
|
||||
setBusy(true); setErr("");
|
||||
try { onRestored(await sendJson<Assessment>(`${base}/${s.id}`, "POST", { action: "restore" })); await load(); setCompare(null); }
|
||||
catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
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="Versions"
|
||||
onClick={(ev) => { if (ev.target === ev.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">Versions</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] text-[var(--text-secondary)]">
|
||||
A snapshot is taken automatically whenever a Word file or deck is exported or the report is printed, so you can
|
||||
always reproduce what a customer saw.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="field max-w-[360px]" placeholder="Name this version, e.g. “Sent to CFO”" value={label}
|
||||
onChange={(ev) => setLabel(ev.target.value)} onKeyDown={(ev) => { if (ev.key === "Enter") save(); }} />
|
||||
<button className="btn btn-primary" onClick={save} disabled={busy}>Save snapshot</button>
|
||||
</div>
|
||||
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
|
||||
|
||||
{list === null ? <p className="text-[13px] text-[var(--text-muted)]">Loading…</p> : list.length === 0 ? (
|
||||
<p className="text-[13px] text-[var(--text-muted)]">No versions yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border">
|
||||
{list.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3.5 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium">{s.label}</div>
|
||||
<div className="text-[11.5px] text-[var(--text-muted)]">
|
||||
{TRIGGER[s.trigger]} · {new Date(s.createdAt).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto tabnum text-right text-[12px] text-[var(--text-secondary)]">
|
||||
{money(s.headline.netAnnual, true)}/yr · {money(s.headline.horizonNet, true)} net · {s.headline.tools} tools
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="btn btn-sm" onClick={() => getJson<Snapshot>(`${base}/${s.id}`).then(setCompare).catch((e) => setErr(errorMessage(e)))}>Compare</button>
|
||||
<button className="btn btn-sm" onClick={() => restore(s)} disabled={busy}>Restore</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{compare && <Compare then={compare} now={a} catalog={catalog} onClose={() => setCompare(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Compare({ then, now, catalog, onClose }: { then: Snapshot; now: Assessment; catalog: Catalog; onClose: () => void }) {
|
||||
const d = useMemo(() => {
|
||||
const ev = (x: Assessment) => evaluate(x, catalog, findScenario(x, x.presentedScenario));
|
||||
const a = ev(then.assessment), b = ev(now);
|
||||
const key = (c: Assessment["candidates"][number]) => c.id;
|
||||
const before = new Map(then.assessment.candidates.map((c) => [key(c), c]));
|
||||
const after = new Map(now.candidates.map((c) => [key(c), c]));
|
||||
const added = now.candidates.filter((c) => !before.has(key(c)));
|
||||
const removed = then.assessment.candidates.filter((c) => !after.has(key(c)));
|
||||
const changed = now.candidates.flatMap((c) => {
|
||||
const o = before.get(key(c));
|
||||
if (!o) return [];
|
||||
const diffs: string[] = [];
|
||||
if (o.annualSpend !== c.annualSpend) diffs.push(`spend ${money(o.annualSpend, true)} → ${money(c.annualSpend, true)}`);
|
||||
if (o.included !== c.included) diffs.push(c.included ? "included" : "excluded");
|
||||
if (o.productIds.join() !== c.productIds.join()) diffs.push("replacement changed");
|
||||
if (o.renewalDate !== c.renewalDate) diffs.push(`renewal ${o.renewalDate ? dateLabel(o.renewalDate) : "—"} → ${c.renewalDate ? dateLabel(c.renewalDate) : "—"}`);
|
||||
if (o.savingsPct !== c.savingsPct) diffs.push(`savings ${o.savingsPct}% → ${c.savingsPct}%`);
|
||||
if (o.migrationCost !== c.migrationCost) diffs.push(`migration ${money(o.migrationCost, true)} → ${money(c.migrationCost, true)}`);
|
||||
return diffs.length ? [{ c, diffs }] : [];
|
||||
});
|
||||
return {
|
||||
rows: [
|
||||
["Net annual benefit", a.cash.runRateNet, b.cash.runRateNet],
|
||||
[`${now.horizonYears}-year net`, a.cash.horizonNet, b.cash.horizonNet],
|
||||
["NPV", a.cash.npv, b.cash.npv],
|
||||
["Current cost (TCO)", a.r.addressableTco, b.r.addressableTco],
|
||||
] as [string, number, number][],
|
||||
tools: [a.r.toolsRetired, b.r.toolsRetired],
|
||||
added, removed, changed,
|
||||
};
|
||||
}, [then, now, catalog]);
|
||||
|
||||
return (
|
||||
<div className="fade-up rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="text-[13.5px] font-semibold">“{then.label}” compared with now</h3>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<table className="mb-3 w-full text-[12.5px]">
|
||||
<thead><tr className="border-b text-[var(--text-secondary)]"><th className="pb-1.5 text-left font-semibold" /><th className="pb-1.5 text-right font-semibold">Then</th><th className="pb-1.5 text-right font-semibold">Now</th><th className="pb-1.5 text-right font-semibold">Change</th></tr></thead>
|
||||
<tbody>
|
||||
{d.rows.map(([k, x, y]) => (
|
||||
<tr key={k} className="border-b border-[var(--grid)]">
|
||||
<td className="py-1.5">{k}</td>
|
||||
<td className="py-1.5 text-right tabnum">{money(x)}</td>
|
||||
<td className="py-1.5 text-right tabnum">{money(y)}</td>
|
||||
<td className="py-1.5 text-right tabnum font-semibold" style={{ color: y - x > 0 ? "var(--success-text)" : y - x < 0 ? "var(--critical)" : undefined }}>
|
||||
{y - x === 0 ? "—" : `${y - x > 0 ? "+" : "−"}${money(Math.abs(y - x))}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr><td className="py-1.5">Tools</td><td className="py-1.5 text-right tabnum">{d.tools[0]}</td><td className="py-1.5 text-right tabnum">{d.tools[1]}</td><td className="py-1.5 text-right tabnum">{d.tools[1] - d.tools[0] || "—"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{d.added.length + d.removed.length + d.changed.length === 0 ? (
|
||||
<p className="text-[12.5px] text-[var(--text-muted)]">No tool lines changed.</p>
|
||||
) : (
|
||||
<ul className="space-y-1 text-[12.5px]">
|
||||
{d.added.map((c) => <li key={`a-${c.id}`}><span style={{ color: "var(--success-text)" }}>+ Added</span> {candidateLabel(c) || "Untitled"}</li>)}
|
||||
{d.removed.map((c) => <li key={`r-${c.id}`}><span style={{ color: "var(--critical)" }}>− Removed</span> {candidateLabel(c) || "Untitled"}</li>)}
|
||||
{d.changed.map(({ c, diffs }) => <li key={`c-${c.id}`}><span className="text-[var(--accent)]">~ Changed</span> {candidateLabel(c)}: {diffs.join(", ")}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user