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:
243
src/app/api/assessments/[id]/deck/route.ts
Normal file
243
src/app/api/assessments/[id]/deck/route.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import PptxGenJS from "pptxgenjs";
|
||||
import { createSnapshot, getAssessment, getBranding, getCatalog } from "@/lib/store";
|
||||
import { customerSafe } from "@/lib/redact";
|
||||
import { deliverable } from "@/lib/deliverable";
|
||||
import { allScenarios, evaluate } from "@/lib/finance";
|
||||
import { WAVE_LABEL } from "@/lib/scoring";
|
||||
import { monthLabel, quarterLabel } from "@/lib/timeline";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
import { imageSize } from "@/lib/images";
|
||||
|
||||
// Brand palette (light theme). One approach per deck: white backgrounds throughout.
|
||||
const DARK = "1B1D36", NAVY = "005C8A", AZURE = "0098C7", AQUA = "007B8C", GREEN = "23800A", PURPLE = "6C4B94";
|
||||
const MUTED = "53565A", RULE = "D9DAE0", WELL = "F3F4F7";
|
||||
const FONT = "Arial";
|
||||
|
||||
type Slide = PptxGenJS.Slide;
|
||||
|
||||
/**
|
||||
* Executive deck for the presented scenario. Built from the redacted
|
||||
* assessment, with net figures only — our investment is never itemized.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const stored = await getAssessment(id);
|
||||
if (!stored) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const [catalog, brand] = await Promise.all([getCatalog(), getBranding()]);
|
||||
const a = customerSafe(stored);
|
||||
const { e, summary, nextSteps } = deliverable(a, catalog);
|
||||
const { r, cash, plan } = e;
|
||||
const ACCENT = (brand.accent || `#${NAVY}`).replace("#", "").toUpperCase();
|
||||
const prod = (pid: string) => catalog.products.find((p) => p.id === pid)?.name ?? pid;
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = "LAYOUT_WIDE"; // 13.33 × 7.5 in
|
||||
pptx.title = `${a.customerName} — Software portfolio rationalization`;
|
||||
pptx.company = brand.companyName || "Converge";
|
||||
|
||||
const logo = (s: Slide, dataUrl: string, x: number, y: number, maxW: number, maxH: number) => {
|
||||
const size = imageSize(dataUrl);
|
||||
if (!size) return;
|
||||
const scale = Math.min(maxW / size.w, maxH / size.h);
|
||||
s.addImage({ data: dataUrl.replace(/^data:/, ""), x, y, w: size.w * scale, h: size.h * scale });
|
||||
};
|
||||
|
||||
const frame = (title: string, kicker?: string) => {
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: "FFFFFF" };
|
||||
s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 13.33, h: 0.08, fill: { color: ACCENT }, line: { color: ACCENT } });
|
||||
if (kicker) s.addText(kicker.toUpperCase(), { x: 0.6, y: 0.35, w: 10, h: 0.3, fontFace: FONT, fontSize: 10, bold: true, color: ACCENT, charSpacing: 2 });
|
||||
s.addText(title, { x: 0.6, y: 0.62, w: 12, h: 0.6, fontFace: FONT, fontSize: 26, bold: true, color: DARK });
|
||||
s.addText(`${a.customerName} · ${e.scenario.name} scenario`, { x: 0.6, y: 7.05, w: 8, h: 0.25, fontFace: FONT, fontSize: 9, color: MUTED });
|
||||
if (brand.logo) logo(s, brand.logo, 11.4, 6.98, 1.4, 0.34);
|
||||
return s;
|
||||
};
|
||||
|
||||
/* 1 — title */
|
||||
{
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: "FFFFFF" };
|
||||
s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.35, h: 7.5, fill: { color: ACCENT }, line: { color: ACCENT } });
|
||||
let lx = 1.0;
|
||||
if (brand.logo) { logo(s, brand.logo, lx, 0.8, 2.4, 0.7); lx += 2.8; }
|
||||
if (a.customerLogo) logo(s, a.customerLogo, lx, 0.8, 2.4, 0.7);
|
||||
s.addText("SOFTWARE PORTFOLIO RATIONALIZATION", { x: 1.0, y: 2.6, w: 11, h: 0.4, fontFace: FONT, fontSize: 14, bold: true, color: ACCENT, charSpacing: 3 });
|
||||
s.addText(a.customerName, { x: 1.0, y: 3.0, w: 11, h: 1.1, fontFace: FONT, fontSize: 44, bold: true, color: DARK });
|
||||
const meta = [a.preparedFor && `Prepared for ${a.preparedFor}`, a.preparedBy && `Prepared by ${a.preparedBy}`,
|
||||
new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })].filter(Boolean).join(" · ");
|
||||
s.addText(meta, { x: 1.0, y: 4.2, w: 11, h: 0.4, fontFace: FONT, fontSize: 14, color: MUTED });
|
||||
}
|
||||
|
||||
/* 2 — headline */
|
||||
{
|
||||
const s = frame("The value of consolidating", "Executive summary");
|
||||
s.addText(summary, { x: 0.6, y: 1.35, w: 12.1, h: 1.3, fontFace: FONT, fontSize: 14, color: MUTED, valign: "top" });
|
||||
const tiles: [string, string, string][] = [
|
||||
["Net annual benefit", money(cash.runRateNet, true), "At full run-rate"],
|
||||
[`${a.horizonYears}-year net benefit`, money(cash.horizonNet, true), `NPV ${money(cash.npv, true)}`],
|
||||
["Payback", cash.paybackMonth === null ? "Beyond horizon" : cash.paybackMonth === 0 ? "Immediate" : `Month ${cash.paybackMonth}`, cash.irr === null ? "" : `IRR ${Math.round(cash.irr)}%`],
|
||||
["Tool footprint", `${r.toolsRetired} → ${r.toolsAfter}`, `${r.vendorsRetired} vendors → ${r.vendorsAfter}`],
|
||||
];
|
||||
tiles.forEach(([k, v, sub], i) => {
|
||||
const x = 0.6 + i * 3.1;
|
||||
s.addShape(pptx.ShapeType.rect, { x, y: 3.0, w: 2.9, h: 2.1, fill: { color: WELL }, line: { color: WELL } });
|
||||
s.addText(k.toUpperCase(), { x: x + 0.2, y: 3.15, w: 2.6, h: 0.3, fontFace: FONT, fontSize: 10, bold: true, color: MUTED });
|
||||
s.addText(v, { x: x + 0.2, y: 3.55, w: 2.6, h: 0.8, fontFace: FONT, fontSize: 30, bold: true, color: i === 0 ? GREEN : i === 1 ? ACCENT : DARK });
|
||||
if (sub) s.addText(sub, { x: x + 0.2, y: 4.45, w: 2.6, h: 0.3, fontFace: FONT, fontSize: 12, color: MUTED });
|
||||
});
|
||||
s.addText(`Current cost of ownership ${money(r.addressableTco)} a year across ${r.toolsRetired} tools.`, { x: 0.6, y: 5.5, w: 12, h: 0.35, fontFace: FONT, fontSize: 13, color: DARK });
|
||||
}
|
||||
|
||||
/* 3 — landscape & overlap */
|
||||
{
|
||||
const s = frame("Where the customer pays more than once", "Current landscape");
|
||||
const rows = [...r.byDomain].sort((x, y) => y.count - x.count || y.spend - x.spend).slice(0, 12);
|
||||
const head = ["Capability", "Tools", "Vendors", "Current cost", "Net annual benefit"].map((t) => ({ text: t, options: { bold: true, color: "FFFFFF", fill: { color: DARK } } }));
|
||||
const body = rows.map((d) => [
|
||||
{ text: d.name, options: {} },
|
||||
{ text: String(d.count), options: { bold: d.count > 1, color: d.count > 1 ? "FFFFFF" : DARK, fill: { color: d.count >= 3 ? NAVY : d.count === 2 ? AZURE : "FFFFFF" }, align: "center" as const } },
|
||||
{ text: String(d.vendors), options: { align: "center" as const } },
|
||||
{ text: money(d.spend), options: { align: "right" as const } },
|
||||
{ text: money(d.savings), options: { align: "right" as const, color: GREEN, bold: true } },
|
||||
]);
|
||||
s.addTable([head, ...body], { x: 0.6, y: 1.4, w: 12.1, colW: [5.3, 1.2, 1.2, 2.2, 2.2], fontFace: FONT, fontSize: 12, color: DARK, border: { type: "solid", pt: 0.5, color: RULE }, rowH: 0.36 });
|
||||
const redundant = r.byDomain.filter((d) => d.count > 1);
|
||||
if (redundant.length) {
|
||||
s.addText(`${redundant.length} categories run more than one tool — ${money(redundant.reduce((t, d) => t + d.spend, 0))} of cost where capability is duplicated.`,
|
||||
{ x: 0.6, y: 6.5, w: 12, h: 0.35, fontFace: FONT, fontSize: 12, italic: true, color: MUTED });
|
||||
}
|
||||
}
|
||||
|
||||
/* 4 — value by business unit */
|
||||
{
|
||||
const s = frame("Where the value lands", "Value by business unit");
|
||||
// Horizontal bar charts draw bottom-up, so reverse to put the largest at the top.
|
||||
const bus = r.byBu.filter((b) => b.savings > 0).reverse();
|
||||
if (bus.length) {
|
||||
s.addChart(pptx.ChartType.bar, [{ name: "Net annual benefit", labels: bus.map((b) => b.name), values: bus.map((b) => Math.round(b.savings)) }], {
|
||||
x: 0.6, y: 1.4, w: 7.4, h: 5.3, barDir: "bar", chartColors: bus.map((b) => b.color.replace("#", "")),
|
||||
catAxisLabelFontSize: 11, valAxisLabelFontSize: 10, valAxisLabelFormatCode: "$#,##0,,\"M\"",
|
||||
showValue: true, dataLabelFormatCode: "$#,##0,,.0\"M\"", dataLabelFontSize: 10,
|
||||
valGridLine: { color: RULE, size: 0.5 }, catGridLine: { style: "none" },
|
||||
});
|
||||
}
|
||||
const list = r.byBu.slice(0, 7).map((b) => ({
|
||||
text: `${b.name}\n`, options: { bold: true, fontSize: 12, color: DARK, breakLine: false },
|
||||
}));
|
||||
s.addText(r.byBu.slice(0, 7).flatMap((b, i) => [
|
||||
list[i],
|
||||
{ text: `${b.count} tool${b.count === 1 ? "" : "s"} → ${b.productIds.map(prod).slice(0, 3).join(", ")}${b.productIds.length > 3 ? "…" : ""}\n`, options: { fontSize: 10.5, color: MUTED, breakLine: false } },
|
||||
]), { x: 8.3, y: 1.4, w: 4.4, h: 5.3, fontFace: FONT, valign: "top", paraSpaceAfter: 6 });
|
||||
}
|
||||
|
||||
/* 5 — roadmap */
|
||||
{
|
||||
const s = frame("A sequence that de-risks the move", "Rationalization roadmap");
|
||||
([1, 2, 3] as const).forEach((w, k) => {
|
||||
const items = r.byWave[w];
|
||||
const x = 0.6 + k * 4.1;
|
||||
const color = [ACCENT, PURPLE, AQUA][k];
|
||||
s.addShape(pptx.ShapeType.rect, { x, y: 1.4, w: 3.9, h: 0.08, fill: { color }, line: { color } });
|
||||
s.addText(WAVE_LABEL[w], { x, y: 1.55, w: 3.9, h: 0.4, fontFace: FONT, fontSize: 14, bold: true, color: DARK });
|
||||
const savings = items.reduce((t, c) => t + r.metrics.get(c.id)!.annualSavings, 0);
|
||||
s.addText(`${money(savings, true)} net per year · ${items.length} tools`, { x, y: 1.95, w: 3.9, h: 0.3, fontFace: FONT, fontSize: 11, color: GREEN, bold: true });
|
||||
const lines = items.slice(0, 9).map((c) => ({
|
||||
text: `${`${c.incumbentVendor} ${c.incumbentProduct}`.trim()} → ${c.productIds.map(prod).join(", ") || "—"}`,
|
||||
options: { bullet: true, fontSize: 10.5, color: DARK, breakLine: true },
|
||||
}));
|
||||
if (items.length > 9) lines.push({ text: `+ ${items.length - 9} more`, options: { bullet: false, fontSize: 10, color: MUTED, breakLine: true } });
|
||||
s.addText(lines.length ? lines : [{ text: "Nothing in this wave.", options: { bullet: false, fontSize: 10.5, color: MUTED, breakLine: true } }],
|
||||
{ x, y: 2.35, w: 3.9, h: 4.4, fontFace: FONT, valign: "top", paraSpaceAfter: 4 });
|
||||
});
|
||||
}
|
||||
|
||||
/* 6 — implementation timeline */
|
||||
{
|
||||
const s = frame(`${plan.completion} months to full run-rate`, "Implementation timeline");
|
||||
const items = plan.items.slice(0, 20);
|
||||
const span = Math.max(plan.span, ...items.map((i) => i.end + 1));
|
||||
const X0 = 3.6, X1 = 12.7, Y0 = 1.75, rowH = Math.min(0.24, 4.9 / Math.max(1, items.length));
|
||||
const mx = (m: number) => X0 + ((X1 - X0) * m) / span;
|
||||
const firstMonth = new Date(`${plan.startIso}T00:00:00Z`).getUTCMonth();
|
||||
for (let m = 0; m <= span; m++) {
|
||||
if ((firstMonth + m) % 3) continue;
|
||||
s.addShape(pptx.ShapeType.line, { x: mx(m), y: Y0 - 0.05, w: 0, h: rowH * items.length + 0.1, line: { color: RULE, width: 0.5 } });
|
||||
if ((firstMonth + m) % 6 === 0) s.addText(quarterLabel(plan.startIso, m), { x: mx(m) - 0.5, y: Y0 - 0.35, w: 1, h: 0.25, fontFace: FONT, fontSize: 8.5, color: MUTED, align: "center" });
|
||||
}
|
||||
items.forEach((it, k) => {
|
||||
const y = Y0 + k * rowH;
|
||||
s.addText(it.label, { x: 0.6, y, w: 2.9, h: rowH, fontFace: FONT, fontSize: 9, color: DARK, valign: "middle" });
|
||||
s.addShape(pptx.ShapeType.roundRect, { x: mx(it.start), y: y + rowH * 0.2, w: Math.max(0.05, mx(it.end) - mx(it.start)), h: rowH * 0.6, rectRadius: 0.03,
|
||||
fill: { color: it.color.startsWith("#") ? it.color.slice(1) : ACCENT }, line: { color: it.color.startsWith("#") ? it.color.slice(1) : ACCENT } });
|
||||
if (it.contractEnd !== null) {
|
||||
s.addShape(pptx.ShapeType.diamond, { x: mx(it.contractEnd + 1) - 0.06, y: y + rowH * 0.25, w: 0.12, h: rowH * 0.5, fill: { color: "FFFFFF" }, line: { color: DARK, width: 1 } });
|
||||
}
|
||||
});
|
||||
const more = plan.items.length - items.length;
|
||||
s.addText(`${L(0)} – ${L(plan.completion - 1)} · ${plan.items.length} projects${more > 0 ? ` (first ${items.length} shown)` : ""} · ◇ contract end · savings from ${L(plan.fullRunRate)} at full run-rate`,
|
||||
{ x: 0.6, y: 6.6, w: 12, h: 0.3, fontFace: FONT, fontSize: 10, color: MUTED });
|
||||
}
|
||||
|
||||
/* 7 — cash flow */
|
||||
{
|
||||
const s = frame("Net benefit year by year", "Cash flow");
|
||||
s.addChart(pptx.ChartType.bar, [
|
||||
{ name: "Net", labels: cash.years.map((y) => `Year ${y.year}`), values: cash.years.map((y) => Math.round(y.net)) },
|
||||
{ name: "Cumulative", labels: cash.years.map((y) => `Year ${y.year}`), values: cash.years.map((y) => Math.round(y.cumulative)) },
|
||||
], {
|
||||
x: 0.6, y: 1.4, w: 6.8, h: 5.2, barDir: "col", barGrouping: "clustered", chartColors: [ACCENT, GREEN],
|
||||
showLegend: true, legendPos: "b", legendFontSize: 11, valAxisLabelFormatCode: "$#,##0,,\"M\"", valAxisLabelFontSize: 10,
|
||||
catAxisLabelFontSize: 11, valGridLine: { color: RULE, size: 0.5 },
|
||||
});
|
||||
const head = ["", "Recurring change", "One-time", "Net", "Cumulative"].map((t) => ({ text: t, options: { bold: true, color: "FFFFFF", fill: { color: DARK } } }));
|
||||
const body = cash.years.map((y) => [
|
||||
{ text: `Year ${y.year}`, options: { bold: true } },
|
||||
{ text: money(y.removed - y.subscription), options: { align: "right" as const } },
|
||||
{ text: y.oneTime ? `−${money(y.oneTime)}` : "—", options: { align: "right" as const } },
|
||||
{ text: money(y.net), options: { align: "right" as const, bold: true, color: y.net >= 0 ? GREEN : "B42318" } },
|
||||
{ text: money(y.cumulative), options: { align: "right" as const } },
|
||||
]);
|
||||
s.addTable([head, ...body], { x: 7.7, y: 1.6, w: 5.0, colW: [0.8, 1.2, 0.95, 1.0, 1.05], fontFace: FONT, fontSize: 10.5, color: DARK, border: { type: "solid", pt: 0.5, color: RULE }, rowH: 0.4 });
|
||||
s.addText(`NPV ${money(cash.npv)} at ${a.finance?.discountRate ?? 10}% · payback ${cash.paybackMonth ? `month ${cash.paybackMonth}` : "n/a"} · one-time ${money(cash.oneTimeTotal)}`,
|
||||
{ x: 7.7, y: 3.9, w: 5.0, h: 0.5, fontFace: FONT, fontSize: 11, color: MUTED });
|
||||
}
|
||||
|
||||
/* 8 — scenarios (optional) */
|
||||
if (a.showScenarioComparison) {
|
||||
const s = frame("Scenarios considered", "Sensitivity");
|
||||
const head = ["Scenario", "Tools", "Net / year", `${a.horizonYears}-year net`, "NPV", "Payback"].map((t) => ({ text: t, options: { bold: true, color: "FFFFFF", fill: { color: DARK } } }));
|
||||
const body = allScenarios(a).map((sc) => {
|
||||
const x = evaluate(a, catalog, sc);
|
||||
const on = sc.id === e.scenario.id;
|
||||
const o = { bold: on, color: DARK };
|
||||
return [
|
||||
{ text: `${sc.name}${on ? " (presented)" : ""}`, options: o },
|
||||
{ text: String(x.r.toolsRetired), options: { ...o, align: "right" as const } },
|
||||
{ text: money(x.cash.runRateNet), options: { ...o, align: "right" as const } },
|
||||
{ text: money(x.cash.horizonNet), options: { ...o, align: "right" as const } },
|
||||
{ text: money(x.cash.npv), options: { ...o, align: "right" as const } },
|
||||
{ text: x.cash.paybackMonth ? monthsLabel(x.cash.paybackMonth) : "—", options: { ...o, align: "right" as const } },
|
||||
];
|
||||
});
|
||||
s.addTable([head, ...body], { x: 0.6, y: 1.5, w: 12.1, colW: [3.6, 1.2, 2, 2, 1.9, 1.4], fontFace: FONT, fontSize: 13, border: { type: "solid", pt: 0.5, color: RULE }, rowH: 0.5 });
|
||||
}
|
||||
|
||||
/* 9 — next steps */
|
||||
{
|
||||
const s = frame("Next steps", "Recommended actions");
|
||||
s.addText(nextSteps.map((t, i) => ({ text: t, options: { bullet: { type: "number" as const }, breakLine: i < nextSteps.length - 1, fontSize: 15, color: DARK } })),
|
||||
{ x: 0.6, y: 1.5, w: 12, h: 5.2, fontFace: FONT, valign: "top", paraSpaceAfter: 12 });
|
||||
}
|
||||
|
||||
const buf = (await pptx.write({ outputType: "nodebuffer" })) as Buffer;
|
||||
// What the customer receives is frozen, so it can always be reproduced.
|
||||
await createSnapshot(stored, `Deck export — ${new Date().toLocaleDateString("en-US")}`, "deck").catch(() => {});
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"Content-Disposition": `attachment; filename="${a.id}-executive-deck.pptx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
ImageRun,
|
||||
Packer,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
@@ -12,14 +13,19 @@ import {
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import { getAssessment, getCatalog } from "@/lib/store";
|
||||
import { createSnapshot, getAssessment, getBranding, getCatalog } from "@/lib/store";
|
||||
import { customerSafe } from "@/lib/redact";
|
||||
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import { WAVE_BLURB, WAVE_LABEL } from "@/lib/scoring";
|
||||
import { allScenarios, evaluate } from "@/lib/finance";
|
||||
import { deliverable } from "@/lib/deliverable";
|
||||
import { monthLabel } from "@/lib/timeline";
|
||||
import { imageSize } from "@/lib/images";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
|
||||
const INK = "0B0B0B";
|
||||
const MUTED = "52514E";
|
||||
const ACCENT = "2A78D6";
|
||||
// Brand palette: Dark Blue for text, Navy as the default accent.
|
||||
const INK = "1B1D36";
|
||||
const MUTED = "53565A";
|
||||
const DEFAULT_ACCENT = "005C8A";
|
||||
|
||||
function cell(text: string, opts: { bold?: boolean; fill?: string; align?: "left" | "right" } = {}) {
|
||||
return new TableCell({
|
||||
@@ -40,7 +46,7 @@ function table(headers: string[], rows: string[][], aligns: ("left" | "right")[]
|
||||
rows: [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: headers.map((h, i) => cell(h, { bold: true, fill: "F0EFEC", align: aligns[i] })),
|
||||
children: headers.map((h, i) => cell(h, { bold: true, fill: "EEF1F6", align: aligns[i] })),
|
||||
}),
|
||||
...rows.map((r) => new TableRow({ children: r.map((v, i) => cell(v, { align: aligns[i] })) })),
|
||||
],
|
||||
@@ -54,54 +60,97 @@ const h2 = (t: string) =>
|
||||
const p = (t: string, color = MUTED) =>
|
||||
new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: t, size: 21, color })] });
|
||||
|
||||
function logoRun(dataUrl: string, maxW: number, maxH: number) {
|
||||
const size = imageSize(dataUrl);
|
||||
if (!size) return null;
|
||||
const scale = Math.min(maxW / size.w, maxH / size.h);
|
||||
return new ImageRun({
|
||||
type: size.type,
|
||||
data: Buffer.from(dataUrl.split(",")[1], "base64"),
|
||||
transformation: { width: Math.round(size.w * scale), height: Math.round(size.h * scale) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const stored = await getAssessment(id);
|
||||
if (!stored) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
// Same redaction as the on-screen report: the Word file is a customer artefact.
|
||||
const a = customerSafe(stored);
|
||||
const catalog = await getCatalog();
|
||||
const r = rollup(a, catalog);
|
||||
const [catalog, brand] = await Promise.all([getCatalog(), getBranding()]);
|
||||
const ACCENT = (brand.accent || `#${DEFAULT_ACCENT}`).replace("#", "").toUpperCase();
|
||||
const { e, summary, nextSteps } = deliverable(a, catalog);
|
||||
const { r, cash, plan } = e;
|
||||
const prod = new Map(catalog.products.map((x) => [x.id, x]));
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
const payback = cash.paybackMonth === null ? "Beyond horizon" : cash.paybackMonth === 0 ? "Immediate" : `Month ${cash.paybackMonth}`;
|
||||
|
||||
const logos = [brand.logo, a.customerLogo].filter(Boolean).map((l) => logoRun(l!, 170, 48)).filter(Boolean) as ImageRun[];
|
||||
|
||||
const kpis: string[][] = [
|
||||
["Current annual spend assessed", money(r.addressableSpend)],
|
||||
["Projected annual savings", money(r.totalAnnualSavings)],
|
||||
[`${a.horizonYears}-year net benefit`, money(r.horizonNet)],
|
||||
["One-time migration investment", money(r.totalMigrationCost)],
|
||||
["Blended payback", monthsLabel(r.blendedPaybackMonths)],
|
||||
["Tools consolidated", String(r.toolsRetired)],
|
||||
["Vendors retired", String(r.vendorsRetired)],
|
||||
["Spend reduction", `${r.savingsPct.toFixed(0)}%`],
|
||||
["Current annual cost of ownership", money(r.addressableTco)],
|
||||
["Net annual benefit at full run-rate", money(cash.runRateNet)],
|
||||
[`${a.horizonYears}-year net benefit (plan-adjusted)`, money(cash.horizonNet)],
|
||||
[`Net present value at ${a.finance?.discountRate ?? 10}%`, money(cash.npv)],
|
||||
["Payback", payback],
|
||||
...(cash.irr === null ? [] : [["Internal rate of return", `${Math.round(cash.irr)}%`]]),
|
||||
["Tool footprint", `${r.toolsRetired} → ${r.toolsAfter} tools`],
|
||||
["Vendors", `${r.vendorsRetired} → ${r.vendorsAfter}`],
|
||||
];
|
||||
|
||||
const children = [
|
||||
new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: "SOFTWARE PORTFOLIO RATIONALIZATION", bold: true, size: 18, color: ACCENT })] }),
|
||||
...(logos.length ? [new Paragraph({ spacing: { after: 240 }, children: logos.flatMap((l, i) => (i ? [new TextRun({ text: " " }), l] : [l])) })] : []),
|
||||
new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: `SOFTWARE PORTFOLIO RATIONALIZATION${brand.companyName ? ` · ${brand.companyName.toUpperCase()}` : ""}`, bold: true, size: 18, color: ACCENT })] }),
|
||||
new Paragraph({ spacing: { after: 80 }, children: [new TextRun({ text: a.customerName || "Customer", bold: true, size: 48, color: INK })] }),
|
||||
p(
|
||||
[a.industry, a.preparedFor && `Prepared for ${a.preparedFor}`, a.preparedBy && `Prepared by ${a.preparedBy}`, a.opportunityRef]
|
||||
.filter(Boolean)
|
||||
.join(" • "),
|
||||
),
|
||||
p(new Date(a.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })),
|
||||
p(`${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} · ${e.scenario.name} scenario`),
|
||||
|
||||
h1("Executive summary"),
|
||||
...(a.executiveSummary.trim()
|
||||
? a.executiveSummary.split(/\n{2,}/).map((s) => p(s.trim(), INK))
|
||||
: [
|
||||
p(
|
||||
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ${money(r.totalAnnualSavings)} of annual run-rate cost — a ${r.savingsPct.toFixed(0)}% reduction against ${money(r.addressableSpend)} of assessed spend. Net of ${money(r.totalMigrationCost)} in migration investment, the ${a.horizonYears}-year net benefit is ${money(r.horizonNet)}, with blended payback at ${monthsLabel(r.blendedPaybackMonths)}.`,
|
||||
INK,
|
||||
),
|
||||
]),
|
||||
...summary.split(/\n{2,}/).map((s) => p(s.trim(), INK)),
|
||||
|
||||
h1("The financial case"),
|
||||
table(["Measure", "Value"], kpis, ["left", "right"]),
|
||||
|
||||
h1("Rationalization roadmap"),
|
||||
h2("Year-by-year cash flow"),
|
||||
table(
|
||||
["", "Recurring change", "One-time", "Net", "Cumulative"],
|
||||
cash.years.map((y) => [`Year ${y.year} (${L((y.year - 1) * 12)} – ${L(y.year * 12 - 1)})`, money(y.removed - y.subscription), y.oneTime ? `−${money(y.oneTime)}` : "—", money(y.net), money(y.cumulative)]),
|
||||
["left", "right", "right", "right", "right"],
|
||||
),
|
||||
];
|
||||
|
||||
if (a.showScenarioComparison) {
|
||||
children.push(h2("Scenarios considered"));
|
||||
children.push(table(
|
||||
["Scenario", "Tools", "Net / year", `${a.horizonYears}-year net`, "NPV", "Payback"],
|
||||
allScenarios(a).map((sc) => {
|
||||
const x = evaluate(a, catalog, sc);
|
||||
return [`${sc.name}${sc.id === e.scenario.id ? " (presented)" : ""}`, String(x.r.toolsRetired), money(x.cash.runRateNet), money(x.cash.horizonNet), money(x.cash.npv), x.cash.paybackMonth ? `Month ${x.cash.paybackMonth}` : "—"];
|
||||
}),
|
||||
["left", "right", "right", "right", "right", "right"],
|
||||
));
|
||||
}
|
||||
|
||||
children.push(h1("Where the customer pays more than once"));
|
||||
children.push(table(
|
||||
["Capability", "Tools", "Vendors", "Current cost", "Net annual benefit"],
|
||||
[...r.byDomain].sort((x, y) => y.count - x.count || y.spend - x.spend).map((d) => [d.name, String(d.count), String(d.vendors), money(d.spend), money(d.savings)]),
|
||||
["left", "right", "right", "right", "right"],
|
||||
));
|
||||
|
||||
children.push(h1("Value by business unit"));
|
||||
children.push(table(
|
||||
["Business unit", "Tools displaced", "Our products", "Net annual benefit", `${a.horizonYears}-year net`],
|
||||
r.byBu.map((b) => [b.name, String(b.count), b.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", "), money(b.savings), money(b.horizonNet)]),
|
||||
["left", "right", "left", "right", "right"],
|
||||
));
|
||||
|
||||
children.push(h1("Rationalization roadmap"));
|
||||
for (const w of [1, 2, 3] as const) {
|
||||
const items = r.byWave[w];
|
||||
if (!items.length) continue;
|
||||
@@ -109,13 +158,13 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
children.push(p(WAVE_BLURB[w]));
|
||||
children.push(
|
||||
table(
|
||||
["Incumbent tool", "Replaced by", "Annual spend", "Annual saving", "Payback"],
|
||||
["Incumbent tool", "Replaced by", "Current cost", "Net annual benefit", "Payback"],
|
||||
items.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return [
|
||||
`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—",
|
||||
c.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", ") || "—",
|
||||
money(c.annualSpend),
|
||||
money(m.tco),
|
||||
money(m.annualSavings),
|
||||
monthsLabel(m.paybackMonths),
|
||||
];
|
||||
@@ -125,14 +174,13 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
);
|
||||
}
|
||||
|
||||
children.push(h1("Consolidation detail by capability"));
|
||||
children.push(
|
||||
table(
|
||||
["Capability domain", "Tools", "Current spend", "Annual saving"],
|
||||
r.byDomain.map((d) => [d.name, String(d.count), money(d.spend), money(d.savings)]),
|
||||
["left", "right", "right", "right"],
|
||||
),
|
||||
);
|
||||
children.push(h1("Implementation plan"));
|
||||
children.push(p(`${plan.items.length} projects from ${L(0)} to ${L(Math.max(0, plan.completion - 1))}; full run-rate savings from ${L(plan.fullRunRate)}.`));
|
||||
children.push(table(
|
||||
["Project", "Replacement", "Start", "Cutover", "Contract ends"],
|
||||
plan.items.map((i) => [`${i.label}${i.bridge ? " (bridge extension)" : ""}`, i.products, L(i.start), L(i.end - 1), i.contractEnd === null ? "—" : L(i.contractEnd)]),
|
||||
["left", "left", "right", "right", "right"],
|
||||
));
|
||||
|
||||
children.push(h1("Assessment detail"));
|
||||
children.push(
|
||||
@@ -163,21 +211,26 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
}
|
||||
}
|
||||
|
||||
children.push(h1("Next steps"));
|
||||
nextSteps.forEach((s, i) => children.push(p(`${i + 1}. ${s}`, INK)));
|
||||
|
||||
children.push(h1("Method"));
|
||||
children.push(
|
||||
p(
|
||||
`Each candidate is scored on functional parity, ease of switching and time to ROI, weighted ${a.weights.capability}/${a.weights.switchEase}/${a.weights.timeToRoi} with a ${a.weights.spend} weighting on spend at stake. Scores of 65 and above are sequenced into Wave 1, 45–64 into Wave 2, and the remainder into Wave 3. Savings are modelled as a stated percentage of current annual spend per tool and held flat across the ${a.horizonYears}-year horizon; migration cost is a one-time charge in year one. Figures are estimates for planning and are not a quotation.`,
|
||||
`Current cost is each tool's total cost of ownership: licence, supporting infrastructure, administration effort and other run costs. Net benefit is the cost removed once a tool is retired, less the cost of the replacement. Savings begin when each migration completes, or when the incumbent contract ends if later; one-time costs fall when each migration starts. NPV discounts monthly cash flows at ${a.finance?.discountRate ?? 10}% a year over the ${a.horizonYears}-year horizon. Candidates are scored on functional parity, ease of switching and time to ROI, weighted ${a.weights.capability}/${a.weights.switchEase}/${a.weights.timeToRoi} with a ${a.weights.spend} weighting on cost at stake. Figures are estimates for planning and are not a quotation.`,
|
||||
),
|
||||
);
|
||||
|
||||
const doc = new Document({
|
||||
creator: a.preparedBy || "Converge",
|
||||
creator: a.preparedBy || brand.companyName || "Converge",
|
||||
title: `${a.customerName} — Software Portfolio Rationalization`,
|
||||
styles: { default: { document: { run: { font: "Aptos", size: 21, color: MUTED } } } },
|
||||
styles: { default: { document: { run: { font: "Arial", size: 21, color: MUTED } } } },
|
||||
sections: [{ properties: { page: { margin: { top: 1000, bottom: 1000, left: 1000, right: 1000 } } }, children }],
|
||||
});
|
||||
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
// What the customer receives is frozen, so it can always be reproduced.
|
||||
await createSnapshot(stored, `Word export — ${new Date().toLocaleDateString("en-US")}`, "word").catch(() => {});
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
|
||||
@@ -2,14 +2,23 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Assessment, Candidate, Catalog, IntelImportResult, IntelProposal, KnowledgeBase, Survey, Weights } from "@/lib/types";
|
||||
import { DEFAULT_WEIGHTS, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import { dateLabel, money, monthsLabel } from "@/lib/format";
|
||||
import { ChartFrame, Empty, Quadrant, StatTile } from "@/components/charts";
|
||||
import type { Assessment, Candidate, Catalog, DealStatus, IntelImportResult, IntelProposal, KnowledgeBase, PlanOverride, Survey, Weights } from "@/lib/types";
|
||||
import { DEFAULT_WEIGHTS, TCO_FALLBACK, WAVE_LABEL } from "@/lib/scoring";
|
||||
import { evaluate } from "@/lib/finance";
|
||||
import { entitlementFlags } from "@/lib/shelfware";
|
||||
import { findEntry, candidateLabel } from "@/lib/kb";
|
||||
import { STATUS_LABEL } from "@/lib/portfolio";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { ChartFrame, Empty, Quadrant } from "@/components/charts";
|
||||
import CandidateRow from "@/components/CandidateRow";
|
||||
import IntelImport from "@/components/IntelImport";
|
||||
import AccountIntelStrip from "@/components/AccountIntelStrip";
|
||||
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
|
||||
import { BuBreakdown, ImplementationTimeline } from "@/components/Analysis";
|
||||
import { FinancePanel, HeadlineTiles, ScenarioPanel } from "@/components/Finance";
|
||||
import { OverlapMap } from "@/components/Overlap";
|
||||
import { ShelfwarePanel } from "@/components/Shelfware";
|
||||
import SnapshotPanel from "@/components/Snapshots";
|
||||
import { LogoInput } from "@/components/LogoInput";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import SurveyPanel from "@/components/SurveyPanel";
|
||||
import SurveyReview, { kbEntryFor, toolVendorProduct } from "@/components/SurveyReview";
|
||||
@@ -36,6 +45,7 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
const [surveys, setSurveys] = useState<Survey[]>([]);
|
||||
const [showSurveys, setShowSurveys] = useState(false);
|
||||
const [reviewing, setReviewing] = useState<Survey | null>(null);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -99,7 +109,37 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
});
|
||||
}, [save]);
|
||||
|
||||
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
|
||||
// The workbench shows the assessment as it stands (the Expected scenario).
|
||||
const ev = useMemo(() => (a && catalog ? evaluate(a, catalog) : null), [a, catalog]);
|
||||
const r = ev?.r ?? null;
|
||||
|
||||
// Keep derived line data current: TCO baselines from the knowledge base for
|
||||
// lines that predate them, and shelfware entitlements from the dossier.
|
||||
useEffect(() => {
|
||||
if (!a || !catalog) return;
|
||||
const flags = entitlementFlags(a, catalog);
|
||||
const needsTco = a.candidates.some((c) => !c.tcoDefaults && candidateLabel(c));
|
||||
const flagDrift = a.candidates.some((c) => (flags.get(c.id) ?? undefined) !== (c.alreadyEntitled ?? undefined));
|
||||
if (!flagDrift && !(needsTco && kb)) return;
|
||||
update((d) => {
|
||||
for (const c of d.candidates) {
|
||||
const f = flags.get(c.id);
|
||||
if (f) c.alreadyEntitled = f; else delete c.alreadyEntitled;
|
||||
if (!c.tcoDefaults && kb && candidateLabel(c)) c.tcoDefaults = { ...(findEntry(kb, candidateLabel(c))?.tco ?? TCO_FALLBACK) };
|
||||
}
|
||||
});
|
||||
}, [a, catalog, kb, update]);
|
||||
|
||||
const setPlan = useCallback((id: string, patch: PlanOverride | null) => update((d) => {
|
||||
const c = d.candidates.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
if (!patch) { delete c.plan; return; }
|
||||
const clean: PlanOverride = {};
|
||||
if (patch.start) clean.start = patch.start;
|
||||
if (patch.duration) clean.duration = patch.duration;
|
||||
if (patch.after?.length) clean.after = patch.after;
|
||||
if (Object.keys(clean).length) c.plan = clean; else delete c.plan;
|
||||
}), [update]);
|
||||
|
||||
/** Accepted proposals become real candidates; the dossier itself stays internal. */
|
||||
function acceptIntel(result: IntelImportResult, chosen: IntelProposal[]) {
|
||||
@@ -151,6 +191,7 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
}
|
||||
const e = kbEntryFor(kb, t);
|
||||
const c: Candidate = {
|
||||
tcoDefaults: { ...(e?.tco ?? TCO_FALLBACK) },
|
||||
...newCandidate(t.domain),
|
||||
incumbentVendor: vendor,
|
||||
incumbentProduct: product,
|
||||
@@ -192,7 +233,7 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!a || !catalog || !r) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>;
|
||||
if (!a || !catalog || !r || !ev) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>;
|
||||
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? "Unassigned";
|
||||
|
||||
@@ -247,11 +288,17 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<span className="tabnum min-w-[54px] text-[12px]" style={{ color: status === "error" ? "var(--critical)" : "var(--text-muted)" }}>{statusText}</span>
|
||||
<select className="field py-[6px]" style={{ width: "auto" }} aria-label="Deal status" value={a.status ?? "open"}
|
||||
onChange={(e) => update((d) => { d.status = e.target.value as DealStatus; })}>
|
||||
{(Object.keys(STATUS_LABEL) as DealStatus[]).map((k) => <option key={k} value={k}>{STATUS_LABEL[k]}</option>)}
|
||||
</select>
|
||||
<button className="btn" onClick={() => setShowVersions(true)}>Versions</button>
|
||||
<button className="btn" onClick={() => setShowSurveys(true)}>Questionnaires{surveys.length ? ` · ${surveys.length}` : ""}</button>
|
||||
<button className="btn" onClick={() => setShowImport(true)}>Import dossier</button>
|
||||
<button className="btn" onClick={exportCsv}>CSV</button>
|
||||
<button className="btn" onClick={exportJson}>JSON</button>
|
||||
<a className="btn" href={`/api/assessments/${a.id}/docx`}>Word</a>
|
||||
<a className="btn" href={`/api/assessments/${a.id}/deck`}>Deck</a>
|
||||
<Link className="btn btn-primary" href={`/assessments/${a.id}/report`}>Customer report →</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +314,10 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
<input id={`m-${k}`} className="field" value={a[k]} onChange={(e) => update((d) => { d[k] = e.target.value; })} />
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<LogoInput label="Customer logo (PNG or JPEG — shown on the report, Word file and deck)" value={a.customerLogo ?? ""}
|
||||
onChange={(v) => update((d) => { if (v) d.customerLogo = v; else delete d.customerLogo; })} />
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<label className="label" htmlFor="m-summary">Executive summary (leave blank to auto-generate in the report)</label>
|
||||
<textarea id="m-summary" className="field" rows={3} value={a.executiveSummary}
|
||||
@@ -305,18 +356,17 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
onAccept={(acc, dis) => acceptSurvey(reviewing, acc, dis)} />
|
||||
)}
|
||||
|
||||
{showVersions && (
|
||||
<SnapshotPanel a={a} catalog={catalog} onClose={() => setShowVersions(false)}
|
||||
onRestored={(next) => { if (timer.current) clearTimeout(timer.current); setA(next); setStatus("saved"); }} />
|
||||
)}
|
||||
|
||||
{showImport && (
|
||||
<IntelImport catalog={catalog} onClose={() => setShowImport(false)} onAccept={acceptIntel} />
|
||||
)}
|
||||
|
||||
{/* ---- KPIs ---- */}
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatTile label="Annual spend assessed" value={money(r.addressableSpend)} sub={`${r.toolsRetired} tools · ${r.vendorsRetired} vendors`} />
|
||||
<StatTile label="Projected annual saving" value={money(r.totalAnnualSavings)} tone="good" sub={`${r.savingsPct.toFixed(0)}% of assessed spend`} />
|
||||
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(r.horizonNet)} tone="accent" sub={`After ${money(r.totalMigrationCost)} migration investment`} />
|
||||
<StatTile label="Blended payback" value={monthsLabel(r.blendedPaybackMonths)} sub={`Average score ${r.avgScore.toFixed(0)}/100`} />
|
||||
<ToolFootprintTile r={r} />
|
||||
</div>
|
||||
<HeadlineTiles e={ev} internal />
|
||||
|
||||
<div className="mb-5 grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<WeightsPanel a={a} update={update} />
|
||||
@@ -346,11 +396,19 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-5"><FinancePanel a={a} e={ev} update={update} /></div>
|
||||
<div className="mb-5"><ScenarioPanel a={a} catalog={catalog} update={update} /></div>
|
||||
|
||||
<div className="mb-5 grid gap-4 xl:grid-cols-2">
|
||||
<OverlapMap r={r} catalog={catalog} />
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</div>
|
||||
|
||||
<ImplementationTimeline a={a} r={r} catalog={catalog} onParallel={(n) => update((d) => { d.planParallel = n; })} />
|
||||
<ShelfwarePanel a={a} catalog={catalog} />
|
||||
|
||||
<ImplementationTimeline e={ev} catalog={catalog} onPlan={setPlan}
|
||||
onParallel={(n) => update((d) => { d.planParallel = n; })}
|
||||
onResetAll={() => update((d) => { for (const c of d.candidates) delete c.plan; })} />
|
||||
|
||||
{/* ---- candidates ---- */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import type { Assessment, Branding, Catalog } from "@/lib/types";
|
||||
import { WAVE_BLURB, WAVE_LABEL } from "@/lib/scoring";
|
||||
import { evaluate, findScenario } from "@/lib/finance";
|
||||
import { dateLabel, money, monthsLabel } from "@/lib/format";
|
||||
import { ChartFrame, Empty, HBar, PaybackCurve, Quadrant, StatTile } from "@/components/charts";
|
||||
import { ChartFrame, Empty, Quadrant } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
|
||||
import { errorMessage, getJson } from "@/lib/client";
|
||||
import { BuBreakdown, ImplementationTimeline } from "@/components/Analysis";
|
||||
import { CashFlowTable, CumulativeChart, HeadlineTiles, ScenarioComparison } from "@/components/Finance";
|
||||
import { OverlapMap } from "@/components/Overlap";
|
||||
import { summaryText } from "@/lib/deliverable";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
|
||||
|
||||
@@ -16,6 +20,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
const { id } = use(params);
|
||||
const [a, setA] = useState<Assessment | null>(null);
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [brand, setBrand] = useState<Branding | null>(null);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
@@ -24,43 +29,61 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
setLoadError("");
|
||||
|
||||
// Deliberately the redacted projection, not the full record.
|
||||
Promise.all([getJson<Assessment>(`/api/assessments/${id}/report`), getJson<Catalog>("/api/catalog")])
|
||||
.then(([as, cat]) => { if (!cancelled) { setA(as); setCatalog(cat); } })
|
||||
Promise.all([
|
||||
getJson<Assessment>(`/api/assessments/${id}/report`),
|
||||
getJson<Catalog>("/api/catalog"),
|
||||
getJson<Branding>("/api/settings").catch(() => null),
|
||||
])
|
||||
.then(([as, cat, b]) => { if (!cancelled) { setA(as); setCatalog(cat); setBrand(b); } })
|
||||
.catch((err) => { if (!cancelled) setLoadError(errorMessage(err)); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, attempt]);
|
||||
|
||||
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
|
||||
// The report presents the scenario the seller chose.
|
||||
const e = useMemo(() => (a && catalog ? evaluate(a, catalog, findScenario(a, a.presentedScenario)) : null), [a, catalog]);
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
|
||||
if (!a || !catalog || !r) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading report…</div>;
|
||||
if (!a || !catalog || !e) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading report…</div>;
|
||||
|
||||
const { r, cash, plan } = e;
|
||||
const prodName = (pid: string) => catalog.products.find((p) => p.id === pid)?.name ?? pid;
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? "Unassigned";
|
||||
const accent = brand?.accent || undefined;
|
||||
|
||||
const autoSummary =
|
||||
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ` +
|
||||
`${money(r.totalAnnualSavings)} of annual run-rate cost — a ${r.savingsPct.toFixed(0)}% reduction against ` +
|
||||
`${money(r.addressableSpend)} of assessed spend, and reduces the estate from ${r.toolsRetired} tools to ${r.toolsAfter}. Net of ${money(r.totalMigrationCost)} in one-time migration ` +
|
||||
`investment, the ${a.horizonYears}-year net benefit is ${money(r.horizonNet)}, with blended payback at ` +
|
||||
`${monthsLabel(r.blendedPaybackMonths)}.`;
|
||||
|
||||
async function print() {
|
||||
// What the customer is about to receive is frozen first, so it can always be reproduced.
|
||||
await sendJson(`/api/assessments/${a!.id}/snapshots`, "POST", { label: `Printed report — ${new Date().toLocaleDateString()}`, trigger: "print" }).catch(() => {});
|
||||
window.print();
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-[1180px]">
|
||||
{/* ---- toolbar (screen only) ---- */}
|
||||
<div className="no-print mb-6 flex flex-wrap items-center gap-2">
|
||||
<Link href={`/assessments/${a.id}`} className="text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">← Back to workbench</Link>
|
||||
<span className="text-[12px] text-[var(--text-muted)]">· Presenting the <strong>{e.scenario.name}</strong> scenario</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<a className="btn" href={`/api/assessments/${a.id}/docx`}>Download Word</a>
|
||||
<button className="btn btn-primary" onClick={() => window.print()}>Print / Save as PDF</button>
|
||||
<a className="btn" href={`/api/assessments/${a.id}/deck`}>Download deck</a>
|
||||
<button className="btn btn-primary" onClick={print}>Print / Save as PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- cover ---- */}
|
||||
<header className="avoid-break mb-8 border-b pb-7">
|
||||
<div className="text-[11.5px] font-semibold uppercase tracking-[.1em]" style={{ color: "var(--accent)" }}>
|
||||
Software portfolio rationalization
|
||||
{(brand?.logo || a.customerLogo) && (
|
||||
<div className="mb-5 flex items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{brand?.logo && <img src={brand.logo} alt={brand.companyName || "Our logo"} className="h-9 max-w-[180px] object-contain" />}
|
||||
{brand?.logo && a.customerLogo && <span className="h-7 w-px bg-[var(--border-strong)]" />}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{a.customerLogo && <img src={a.customerLogo} alt={a.customerName} className="h-9 max-w-[180px] object-contain" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11.5px] font-semibold uppercase tracking-[.1em]" style={{ color: accent ?? "var(--accent)" }}>
|
||||
Software portfolio rationalization{brand?.companyName ? ` · ${brand.companyName}` : ""}
|
||||
</div>
|
||||
<h1 className="mt-2 text-[40px] font-semibold leading-[1.1] tracking-tight">{a.customerName}</h1>
|
||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-[var(--text-secondary)]">
|
||||
@@ -80,46 +103,39 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<section className="avoid-break mb-7">
|
||||
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Executive summary</h2>
|
||||
<div className="max-w-[72ch] space-y-3 text-[14.5px] leading-[1.65] text-[var(--text-secondary)]">
|
||||
{(a.executiveSummary.trim() || autoSummary).split(/\n{2,}/).map((p, i) => <p key={i}>{p}</p>)}
|
||||
{summaryText(e).split(/\n{2,}/).map((p, i) => <p key={i}>{p}</p>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- headline numbers ---- */}
|
||||
<section className="avoid-break mb-7 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatTile label="Annual spend assessed" value={money(r.addressableSpend)} sub={`Across ${r.toolsRetired} tools`} />
|
||||
<StatTile label="Projected annual saving" value={money(r.totalAnnualSavings)} tone="good" sub={`${r.savingsPct.toFixed(0)}% run-rate reduction`} />
|
||||
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(r.horizonNet)} tone="accent" sub={`After ${money(r.totalMigrationCost)} migration investment`} />
|
||||
<StatTile label="Blended payback" value={monthsLabel(r.blendedPaybackMonths)} sub={`${r.vendorsRetired} vendor relationships retired`} />
|
||||
<ToolFootprintTile r={r} />
|
||||
</section>
|
||||
<HeadlineTiles e={e} />
|
||||
|
||||
{/* ---- value over time ---- */}
|
||||
<section className="mb-7 grid gap-4 lg:grid-cols-2">
|
||||
<ChartFrame
|
||||
title="Cumulative net benefit"
|
||||
subtitle={`Savings held flat across ${a.horizonYears} years, net of one-time migration cost.`}
|
||||
table={{
|
||||
headers: ["Point", "Net benefit"],
|
||||
rows: Array.from({ length: a.horizonYears + 1 }, (_, i) => [
|
||||
i === 0 ? "Start" : `Year ${i}`, money(r.totalAnnualSavings * i - r.totalMigrationCost),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
<PaybackCurve annualSavings={r.totalAnnualSavings} migrationCost={r.totalMigrationCost} years={a.horizonYears} />
|
||||
<ChartFrame title="Cumulative net benefit"
|
||||
subtitle="Month by month as each tool is retired, net of one-time costs."
|
||||
table={{ headers: ["Year", "Net", "Cumulative"], rows: cash.years.map((y) => [`Year ${y.year}`, money(y.net), money(y.cumulative)]) }}>
|
||||
<CumulativeChart e={e} />
|
||||
</ChartFrame>
|
||||
<section className="card avoid-break p-5">
|
||||
<h3 className="mb-4 text-[14px] font-semibold tracking-tight">Year-by-year cash flow</h3>
|
||||
<CashFlowTable e={e} showSubscription={false} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<ChartFrame
|
||||
title="Annual saving by capability domain"
|
||||
subtitle="Where the consolidation value concentrates."
|
||||
table={{
|
||||
headers: ["Domain", "Tools", "Current spend", "Annual saving"],
|
||||
rows: r.byDomain.map((d) => [d.name, d.count, money(d.spend), money(d.savings)]),
|
||||
}}
|
||||
>
|
||||
<HBar data={r.byDomain.slice(0, 9).map((d) => ({
|
||||
label: d.name, value: d.savings, note: `${d.count} tool${d.count > 1 ? "s" : ""} · ${money(d.spend)} current spend`,
|
||||
}))} />
|
||||
</ChartFrame>
|
||||
{a.showScenarioComparison && (
|
||||
<section className="card avoid-break mb-7 p-5">
|
||||
<h2 className="mb-1 text-[15px] font-semibold tracking-tight">Scenarios considered</h2>
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
Conservative and aggressive cases vary the value realized and the pace of migration.
|
||||
</p>
|
||||
<ScenarioComparison a={a} catalog={catalog} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- where the overlap is ---- */}
|
||||
<section className="mb-7 grid gap-4 lg:grid-cols-2">
|
||||
<OverlapMap r={r} catalog={catalog} />
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</section>
|
||||
|
||||
{/* ---- roadmap ---- */}
|
||||
@@ -127,7 +143,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<h2 className="mb-1 text-[19px] font-semibold tracking-tight">Rationalization roadmap</h2>
|
||||
<p className="mb-4 max-w-[72ch] text-[13.5px] text-[var(--text-secondary)]">
|
||||
Candidates are sequenced by a weighted score of capability parity, switching effort, speed to payback and
|
||||
spend at stake. Wave 1 carries the least risk and the fastest return.
|
||||
cost at stake. Wave 1 carries the least risk and the fastest return.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
@@ -141,7 +157,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<p className="mt-1 text-[12px] leading-snug text-[var(--text-muted)]">{WAVE_BLURB[w]}</p>
|
||||
<div className="mt-2.5 flex items-baseline gap-2">
|
||||
<span className="tabnum text-[19px] font-semibold" style={{ color: "var(--success-text)" }}>{money(savings, true)}</span>
|
||||
<span className="text-[12px] text-[var(--text-muted)]">per year · {items.length} tool{items.length === 1 ? "" : "s"}</span>
|
||||
<span className="text-[12px] text-[var(--text-muted)]">net per year · {items.length} tool{items.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
@@ -157,7 +173,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
→ {c.productIds.map(prodName).join(", ") || "Not yet mapped"}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 tabnum text-[11.5px] text-[var(--text-muted)]">
|
||||
<span>{money(m.annualSavings)}/yr saved</span>
|
||||
<span>{money(m.annualSavings)}/yr net</span>
|
||||
<span>{monthsLabel(m.paybackMonths)} payback</span>
|
||||
{c.renewalDate && <span>Renews {dateLabel(c.renewalDate)}</span>}
|
||||
</div>
|
||||
@@ -173,16 +189,16 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
</section>
|
||||
|
||||
{/* ---- implementation plan ---- */}
|
||||
<ImplementationTimeline a={a} r={r} catalog={catalog} />
|
||||
<ImplementationTimeline e={e} catalog={catalog} />
|
||||
|
||||
{/* ---- quadrant ---- */}
|
||||
<section className="mb-7">
|
||||
<ChartFrame
|
||||
title="Replacement readiness"
|
||||
subtitle="Capability parity against ease of switching, sized by annual spend at stake."
|
||||
subtitle="Capability parity against ease of switching, sized by current cost of ownership."
|
||||
table={{
|
||||
headers: ["Incumbent", "Parity", "Switch ease", "Spend at stake"],
|
||||
rows: r.included.map((c) => [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(c.annualSpend)]),
|
||||
headers: ["Incumbent", "Parity", "Switch ease", "Current cost"],
|
||||
rows: r.included.map((c) => [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(r.metrics.get(c.id)!.tco)]),
|
||||
}}
|
||||
>
|
||||
<Quadrant
|
||||
@@ -190,7 +206,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
points={r.included.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return {
|
||||
id: c.id, x: c.switchEase, y: c.capability, size: Math.max(c.annualSpend, 1),
|
||||
id: c.id, x: c.switchEase, y: c.capability, size: Math.max(m.tco, 1),
|
||||
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
|
||||
sublabel: c.productIds.map(prodName).join(", ") || "Not yet mapped",
|
||||
colorIndex: m.wave - 1, groupLabel: WAVE_LABEL[m.wave],
|
||||
@@ -198,12 +214,6 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
})}
|
||||
/>
|
||||
</ChartFrame>
|
||||
|
||||
</section>
|
||||
|
||||
{/* ---- value by business unit ---- */}
|
||||
<section className="mb-7">
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</section>
|
||||
|
||||
{/* ---- detail table ---- */}
|
||||
@@ -213,7 +223,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--surface-2)]">
|
||||
{["Incumbent tool", "Capability domain", "Proposed platform", "Annual spend", "Annual saving", "Parity", "Switch", "Payback", "Wave"].map((h, i) => (
|
||||
{["Incumbent tool", "Capability domain", "Proposed platform", "Current cost", "Net annual benefit", "Parity", "Switch", "Payback", "Wave"].map((h, i) => (
|
||||
<th key={h} className={`px-3 py-2.5 font-semibold ${i >= 3 ? "text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -226,7 +236,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<td className="px-3 py-2.5 font-medium">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—"}</td>
|
||||
<td className="px-3 py-2.5 text-[var(--text-secondary)]">{domainName(c.domain)}</td>
|
||||
<td className="px-3 py-2.5 text-[var(--text-secondary)]">{c.productIds.map(prodName).join(", ") || "—"}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(c.annualSpend)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(m.tco)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{c.capability}/5</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{c.switchEase}/5</td>
|
||||
@@ -241,7 +251,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-[var(--surface-2)] font-semibold">
|
||||
<td className="px-3 py-2.5" colSpan={3}>Total — {r.toolsRetired} tools, {r.vendorsRetired} vendors</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(r.addressableSpend)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(r.addressableTco)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum" style={{ color: "var(--success-text)" }}>{money(r.totalAnnualSavings)}</td>
|
||||
<td className="px-3 py-2.5" colSpan={4} />
|
||||
</tr>
|
||||
@@ -269,12 +279,14 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<section className="avoid-break border-t pt-5">
|
||||
<h2 className="mb-2 text-[14px] font-semibold tracking-tight">Method and assumptions</h2>
|
||||
<p className="max-w-[80ch] text-[12px] leading-relaxed text-[var(--text-muted)]">
|
||||
Each candidate is scored on capability parity, ease of switching and time to ROI, weighted{" "}
|
||||
{a.weights.capability}/{a.weights.switchEase}/{a.weights.timeToRoi} with a {a.weights.spend} weighting on
|
||||
spend at stake. Scores of 65 and above are sequenced into Wave 1, 45–64 into Wave 2, and the remainder into
|
||||
Wave 3. Savings are modelled as a stated percentage of current annual spend per tool and held flat across
|
||||
the {a.horizonYears}-year horizon; migration cost is treated as a one-time charge in year one. Spend figures
|
||||
are supplied by or estimated with the customer. These are planning estimates, not a quotation.
|
||||
Current cost is each tool’s total cost of ownership: licence, supporting infrastructure, administration
|
||||
effort and other run costs. Net benefit is the cost removed once a tool is retired, less the cost of the
|
||||
replacement. Savings begin when each migration completes, or when the incumbent contract ends if later, following
|
||||
the implementation plan; one-time costs fall when each migration starts. NPV discounts monthly cash flows at{" "}
|
||||
{a.finance?.discountRate ?? 10}% a year over the {a.horizonYears}-year horizon. Candidates are scored on capability
|
||||
parity, ease of switching and time to ROI, weighted {a.weights.capability}/{a.weights.switchEase}/{a.weights.timeToRoi}{" "}
|
||||
with a {a.weights.spend} weighting on cost at stake; scores of 65 and above form Wave 1, 45–64 Wave 2, the rest
|
||||
Wave 3. Figures are planning estimates, not a quotation.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BusinessUnit, Catalog, Product } from "@/lib/types";
|
||||
import { Empty } from "@/components/charts";
|
||||
import { BRAND_SWATCHES } from "@/lib/brand";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
@@ -140,7 +141,7 @@ export default function CatalogPage() {
|
||||
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
|
||||
{(["all", "on", "off"] as const).map((s) => (
|
||||
<button key={s}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium ${state === s ? "bg-[var(--accent)] text-white" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium ${state === s ? "bg-[var(--accent)] text-[var(--accent-ink)]" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
onClick={() => setState(s)}>
|
||||
{s === "all" ? "All" : s === "on" ? "In scope" : "Disabled"}
|
||||
</button>
|
||||
@@ -324,9 +325,8 @@ function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Cat
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{cat.businessUnits.map((b) => (
|
||||
<div key={b.id} className="flex items-center gap-2 rounded-lg border px-2.5 py-2">
|
||||
<input type="color" className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
value={b.color} aria-label={`${buLabel(b)} colour`}
|
||||
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.color = e.target.value; })} />
|
||||
<BrandSwatch value={b.color} label={`${buLabel(b)} colour`}
|
||||
onChange={(hex) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.color = hex; })} />
|
||||
<input className="field py-1 text-[12.5px]" value={b.name} placeholder="Name" aria-label="Business unit name"
|
||||
autoFocus={b.id === addedId}
|
||||
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.name = e.target.value; })} />
|
||||
@@ -344,7 +344,7 @@ function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Cat
|
||||
onClick={() => {
|
||||
let id = "bu", n = 2;
|
||||
while (cat.businessUnits.some((b) => b.id === id)) id = `bu-${n++}`;
|
||||
mutate((c) => { c.businessUnits.push({ id, name: "", short: "", color: "#898781" }); });
|
||||
mutate((c) => { c.businessUnits.push({ id, name: "", short: "", color: BRAND_SWATCHES[c.businessUnits.length % BRAND_SWATCHES.length].hex }); });
|
||||
setAddedId(id);
|
||||
}}>
|
||||
+ Add business unit
|
||||
@@ -352,3 +352,23 @@ function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Cat
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Colour choice limited to the brand palette (Brand Guidelines: no off-palette colours). */
|
||||
function BrandSwatch({ value, label, onChange }: { value: string; label: string; onChange: (hex: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button className="h-7 w-7 rounded border" style={{ background: value }} aria-label={label} aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)} />
|
||||
{open && (
|
||||
<div className="card absolute left-0 top-8 z-20 grid w-[148px] grid-cols-4 gap-1.5 p-2" role="listbox" aria-label={label}>
|
||||
{BRAND_SWATCHES.map((s) => (
|
||||
<button key={s.hex} role="option" aria-selected={value.toLowerCase() === s.hex.toLowerCase()} title={`${s.name} ${s.hex}`}
|
||||
className="h-7 w-7 rounded" style={{ background: s.hex, outline: value.toLowerCase() === s.hex.toLowerCase() ? "2px solid var(--text-primary)" : undefined, outlineOffset: 1 }}
|
||||
onClick={() => { onChange(s.hex); setOpen(false); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,89 +2,107 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-1: #fcfcfb; /* cards, chart surface */
|
||||
--surface-2: #f4f3f0; /* inset wells, table stripes */
|
||||
--plane: #f9f9f7; /* page background */
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--grid: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.10);
|
||||
--border-strong: rgba(11, 11, 11, 0.18);
|
||||
/* VMware by Broadcom palette — see src/lib/brand.ts and "Brand Guidelines.pdf". */
|
||||
--surface-1: #ffffff; /* cards, chart surface */
|
||||
--surface-2: #f0f2f6; /* inset wells, table stripes */
|
||||
--plane: #f7f8fa; /* page background */
|
||||
--text-primary: #1b1d36; /* Dark Blue */
|
||||
--text-secondary: #474a66;
|
||||
--text-muted: #6b6e87;
|
||||
--grid: #e3e5ec;
|
||||
--axis: #c5c8d4;
|
||||
--border: rgba(27, 29, 54, 0.11);
|
||||
--border-strong: rgba(27, 29, 54, 0.20);
|
||||
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #4a3aa7;
|
||||
--series-8: #e34948;
|
||||
--series-1: #005c8a; /* Navy */
|
||||
--series-2: #6c4b94; /* Purple */
|
||||
--series-3: #007b8c; /* Aqua */
|
||||
--series-4: #61a60e; /* Green */
|
||||
--series-5: #0098c7; /* Azure */
|
||||
--series-6: #007da3; /* Light Blue */
|
||||
--series-7: #1b1d36; /* Dark Blue */
|
||||
--series-8: #23800a; /* Green (AA) */
|
||||
|
||||
--good: #0ca30c;
|
||||
--warning: #fab219;
|
||||
--serious: #ec835a;
|
||||
--critical: #d03b3b;
|
||||
--success-text: #006300;
|
||||
--good: #61a60e;
|
||||
--warning: #e3a21a; /* warnings only */
|
||||
--serious: #e3a21a;
|
||||
--critical: #b42318; /* errors and destructive actions only */
|
||||
--success-text: #23800a; /* the guide's AA-safe green */
|
||||
|
||||
--accent: #2a78d6;
|
||||
--accent: #005c8a; /* Navy */
|
||||
--accent-ink: #ffffff;
|
||||
--shadow: 0 1px 2px rgba(11,11,11,.05), 0 8px 24px -12px rgba(11,11,11,.18);
|
||||
--shadow: 0 1px 2px rgba(27,29,54,.05), 0 8px 24px -12px rgba(27,29,54,.18);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--plane: #0d0d0d;
|
||||
/* Brand Darkmode navy as the base, with the guide's dark-theme accents. */
|
||||
--surface-1: #232648;
|
||||
--surface-2: #2b2e55;
|
||||
--plane: #1b1d36;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--axis: #383835;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--border-strong: rgba(255, 255, 255, 0.20);
|
||||
--text-secondary: #c9cbe0;
|
||||
--text-muted: #9a9dbb;
|
||||
--grid: #33375f;
|
||||
--axis: #474b78;
|
||||
--border: rgba(255, 255, 255, 0.11);
|
||||
--border-strong: rgba(255, 255, 255, 0.22);
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--series-1: #0088ef; /* Blue (dark theme) */
|
||||
--series-2: #a468ee; /* Purple (dark theme) */
|
||||
--series-3: #01b9c6; /* Aqua (dark theme) */
|
||||
--series-4: #61a60e; /* Green */
|
||||
--series-5: #0098c7; /* Light Blue */
|
||||
--series-6: #7fb3f5; /* Blue tint */
|
||||
--series-7: #c9a5f5; /* Purple tint */
|
||||
--series-8: #8fd66a; /* Green tint */
|
||||
|
||||
--success-text: #0ca30c;
|
||||
--accent: #3987e5;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
--good: #61a60e;
|
||||
--warning: #e3a21a;
|
||||
--serious: #e3a21a;
|
||||
--critical: #f97066;
|
||||
--success-text: #7cc23a;
|
||||
|
||||
--accent: #0088ef;
|
||||
/* White on #0088EF is ~3.4:1, under AA; dark ink on it is ~5.3:1. */
|
||||
--accent-ink: #0b0c1d;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 24px -12px rgba(0,0,0,.6);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--plane: #0d0d0d;
|
||||
/* Brand Darkmode navy as the base, with the guide's dark-theme accents. */
|
||||
--surface-1: #232648;
|
||||
--surface-2: #2b2e55;
|
||||
--plane: #1b1d36;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--axis: #383835;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--border-strong: rgba(255, 255, 255, 0.20);
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--success-text: #0ca30c;
|
||||
--accent: #3987e5;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
--text-secondary: #c9cbe0;
|
||||
--text-muted: #9a9dbb;
|
||||
--grid: #33375f;
|
||||
--axis: #474b78;
|
||||
--border: rgba(255, 255, 255, 0.11);
|
||||
--border-strong: rgba(255, 255, 255, 0.22);
|
||||
|
||||
--series-1: #0088ef; /* Blue (dark theme) */
|
||||
--series-2: #a468ee; /* Purple (dark theme) */
|
||||
--series-3: #01b9c6; /* Aqua (dark theme) */
|
||||
--series-4: #61a60e; /* Green */
|
||||
--series-5: #0098c7; /* Light Blue */
|
||||
--series-6: #7fb3f5; /* Blue tint */
|
||||
--series-7: #c9a5f5; /* Purple tint */
|
||||
--series-8: #8fd66a; /* Green tint */
|
||||
|
||||
--good: #61a60e;
|
||||
--warning: #e3a21a;
|
||||
--serious: #e3a21a;
|
||||
--critical: #f97066;
|
||||
--success-text: #7cc23a;
|
||||
|
||||
--accent: #0088ef;
|
||||
--accent-ink: #0b0c1d;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 24px -12px rgba(0,0,0,.6);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -161,7 +179,7 @@ select.field { appearance: none; background-image: none; cursor: pointer; }
|
||||
.btn:hover { background: var(--surface-2); border-color: var(--text-muted); }
|
||||
.btn:active { transform: translateY(.5px); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||
.btn-primary:hover { background: color-mix(in oklab, var(--accent) 86%, #000); border-color: transparent; }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-secondary); }
|
||||
.btn-ghost:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<!-- Favicon: the nav logo simplified for 16px — no trend line, heavier bars. -->
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2a78d6"/>
|
||||
<stop offset="1" stop-color="#1baf7a"/>
|
||||
<stop stop-color="#005C8A"/>
|
||||
<stop offset="1" stop-color="#0098C7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#g)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 689 B After Width: | Height: | Size: 689 B |
@@ -4,14 +4,15 @@ import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
|
||||
import { CAPABILITY_LABEL, SWITCH_LABEL, money } from "@/lib/format";
|
||||
import { newEntry } from "@/lib/kb";
|
||||
import { confidenceOf, findDuplicates, mergeEntries, newEntry, pairKey } from "@/lib/kb";
|
||||
import { TCO_FALLBACK } from "@/lib/scoring";
|
||||
import { Empty } from "@/components/charts";
|
||||
import { ConfidenceChip } from "@/components/IncumbentPicker";
|
||||
import ProductPicker from "@/components/ProductPicker";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
type Filter = "all" | "learned" | "seeded" | "unmapped";
|
||||
type Filter = "all" | "learned" | "seeded" | "unmapped" | "review";
|
||||
|
||||
export default function KbPage() {
|
||||
const [kb, setKb] = useState<KnowledgeBase | null>(null);
|
||||
@@ -25,6 +26,7 @@ export default function KbPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
const [showDups, setShowDups] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -67,6 +69,7 @@ export default function KbPage() {
|
||||
if (filter === "learned" && e.observations.length === 0) return false;
|
||||
if (filter === "seeded" && e.observations.length > 0) return false;
|
||||
if (filter === "unmapped" && e.productIds.length > 0) return false;
|
||||
if (filter === "review") { const c = confidenceOf(e); if (!(c.stale || (c.level === "low" && e.observations.length > 0))) return false; }
|
||||
if (!needle) return true;
|
||||
return [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase().includes(needle);
|
||||
})
|
||||
@@ -77,6 +80,7 @@ export default function KbPage() {
|
||||
if (!kb || !catalog) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading knowledge base…</div>;
|
||||
|
||||
const sel = kb.entries.find((e) => e.id === selId) ?? null;
|
||||
const dups = findDuplicates(kb);
|
||||
const learned = kb.entries.filter((e) => e.observations.length > 0).length;
|
||||
const deals = new Set(kb.entries.flatMap((e) => e.observations.map((o) => o.assessmentId))).size;
|
||||
|
||||
@@ -110,6 +114,9 @@ export default function KbPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button className="btn" onClick={() => setShowDups((v) => !v)}>
|
||||
Possible duplicates{dups.length ? ` · ${dups.length}` : ""}
|
||||
</button>
|
||||
<button className="btn" onClick={exportKb}>Export</button>
|
||||
<button className="btn" onClick={add}>+ Add entry</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving}>
|
||||
@@ -120,14 +127,43 @@ export default function KbPage() {
|
||||
|
||||
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
|
||||
|
||||
{showDups && (
|
||||
<div className="card fade-up mb-4 p-5">
|
||||
<div className="mb-1 flex items-center">
|
||||
<h2 className="text-[14px] font-semibold">Possible duplicates</h2>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setShowDups(false)}>Close</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
Duplicates split deal history, so defaults learn slower. Merging keeps the entry you pick, turns the other’s
|
||||
name into an alias and moves its deal history over. Save changes afterwards.
|
||||
</p>
|
||||
{dups.length === 0 ? <p className="text-[13px] text-[var(--text-muted)]">No likely duplicates.</p> : (
|
||||
<ul className="scroll-thin max-h-[360px] space-y-1.5 overflow-y-auto">
|
||||
{dups.slice(0, 60).map((d) => (
|
||||
<li key={pairKey(d.a.id, d.b.id)} className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-[12.5px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<strong>{d.a.label}</strong> <span className="text-[var(--text-muted)]">({d.a.observations.length} deals)</span>
|
||||
{" · "}<strong>{d.b.label}</strong> <span className="text-[var(--text-muted)]">({d.b.observations.length} deals)</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-muted)]">{d.reason}</span>
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={() => mutate((k) => { Object.assign(k, mergeEntries(k, d.a.id, d.b.id)); })}>Keep “{d.a.label}”</button>
|
||||
<button className="btn btn-sm" onClick={() => mutate((k) => { Object.assign(k, mergeEntries(k, d.b.id, d.a.id)); })}>Keep “{d.b.label}”</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => mutate((k) => { k.distinctPairs = [...(k.distinctPairs ?? []), pairKey(d.a.id, d.b.id)]; })}>These are different</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input className="field max-w-[320px]" placeholder="Search incumbent tools…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
|
||||
{(["all", "learned", "seeded", "unmapped"] as const).map((f) => (
|
||||
{(["all", "learned", "seeded", "unmapped", "review"] as const).map((f) => (
|
||||
<button key={f}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium capitalize ${filter === f ? "bg-[var(--accent)] text-white" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium capitalize ${filter === f ? "bg-[var(--accent)] text-[var(--accent-ink)]" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
onClick={() => setFilter(f)}>
|
||||
{f === "all" ? "All" : f === "learned" ? "Proven in deals" : f === "seeded" ? "Not yet used" : "Unmapped"}
|
||||
{f === "all" ? "All" : f === "learned" ? "Proven in deals" : f === "seeded" ? "Not yet used" : f === "unmapped" ? "Unmapped" : "Needs review"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -145,7 +181,7 @@ export default function KbPage() {
|
||||
<tr className="border-b">
|
||||
<th className="py-2.5 pl-4 text-left font-semibold">Incumbent tool</th>
|
||||
<th className="py-2.5 text-left font-semibold">Maps to</th>
|
||||
<th className="w-[96px] py-2.5 pr-4 text-right font-semibold">Confidence</th>
|
||||
<th className="w-[110px] py-2.5 pr-4 text-right font-semibold">Confidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -163,7 +199,7 @@ export default function KbPage() {
|
||||
? e.productIds.map((id) => catalog.products.find((p) => p.id === id)?.name ?? id).join(", ")
|
||||
: <span className="text-[var(--text-muted)]">—</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right"><ConfidenceChip entry={e} /></td>
|
||||
<td className="py-2 pr-4 text-right"><LevelChip entry={e} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -258,6 +294,21 @@ function EntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Total cost defaults</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Num label="Infra (% of licence)" value={entry.tco?.infraPct ?? TCO_FALLBACK.infraPct} min={0} max={200}
|
||||
onChange={(n) => set("tco", { ...(entry.tco ?? TCO_FALLBACK), infraPct: n })} />
|
||||
<Num label="Admin (FTEs)" value={entry.tco?.adminFte ?? TCO_FALLBACK.adminFte} min={0} max={50} step={0.05}
|
||||
onChange={(n) => set("tco", { ...(entry.tco ?? TCO_FALLBACK), adminFte: n })} />
|
||||
<Num label="Other (% of licence)" value={entry.tco?.otherPct ?? TCO_FALLBACK.otherPct} min={0} max={200}
|
||||
onChange={(n) => set("tco", { ...(entry.tco ?? TCO_FALLBACK), otherPct: n })} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--text-muted)]">Copied onto new assessment lines for this tool; sellers can override per line.</p>
|
||||
</div>
|
||||
|
||||
<SourcePanel entry={entry} set={set} />
|
||||
|
||||
<div>
|
||||
<label className="label">Known risks & dependencies</label>
|
||||
<textarea className="field" rows={3} value={entry.risks} onChange={(e) => set("risks", e.target.value)} />
|
||||
@@ -325,3 +376,50 @@ function Num({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LEVEL_STYLE = { high: "var(--success-text)", medium: "var(--accent)", low: "var(--text-muted)" } as const;
|
||||
|
||||
function LevelChip({ entry }: { entry: KbEntry }) {
|
||||
const c = confidenceOf(entry);
|
||||
return (
|
||||
<span className="chip shrink-0" style={{ borderColor: LEVEL_STYLE[c.level], color: LEVEL_STYLE[c.level] }}
|
||||
title={`${c.source}${c.overridden ? " · set by curator" : ""}${c.stale ? " · not reviewed in 12 months" : ""}`}>
|
||||
{c.level[0].toUpperCase() + c.level.slice(1)}{c.stale ? " · review" : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcePanel({ entry, set }: { entry: KbEntry; set: <K extends keyof KbEntry>(k: K, v: KbEntry[K]) => void }) {
|
||||
const c = confidenceOf(entry);
|
||||
return (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Source & confidence</span>
|
||||
<LevelChip entry={entry} />
|
||||
</div>
|
||||
<p className="mb-2 text-[12px] text-[var(--text-secondary)]">{c.source}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label">Curator</label>
|
||||
<input className="field" value={entry.owner ?? ""} onChange={(e) => set("owner", e.target.value || undefined)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reviewed</label>
|
||||
<div className="flex gap-1">
|
||||
<input className="field" type="date" value={entry.reviewedAt?.slice(0, 10) ?? ""} onChange={(e) => set("reviewedAt", e.target.value || undefined)} />
|
||||
<button className="btn btn-ghost btn-sm shrink-0" title="Mark reviewed today" onClick={() => set("reviewedAt", new Date().toISOString().slice(0, 10))}>Today</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Confidence</label>
|
||||
<select className="field" value={entry.confidenceOverride ?? ""} onChange={(e) => set("confidenceOverride", (e.target.value || undefined) as KbEntry["confidenceOverride"])}>
|
||||
<option value="">Automatic</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Assessment, AssessmentSummary } from "@/lib/types";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { Empty } from "@/components/charts";
|
||||
import { STATUS_LABEL } from "@/lib/portfolio";
|
||||
|
||||
const STATUS_COLOR = { open: "var(--accent)", won: "var(--success-text)", paused: "var(--text-muted)", lost: "var(--critical)" } as const;
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
@@ -67,7 +70,7 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
const totalSavings = (rows ?? []).reduce((s, r) => s + r.annualSavings, 0);
|
||||
const totalSavings = (rows ?? []).filter((r) => r.status !== "lost").reduce((s, r) => s + r.annualSavings, 0);
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
@@ -121,15 +124,18 @@ export default function Home() {
|
||||
<Link href={`/assessments/${r.id}`} className="block">
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 className="truncate text-[15.5px] font-semibold tracking-tight">{r.customerName}</h2>
|
||||
<span className="chip ml-auto shrink-0">{r.candidateCount} tools</span>
|
||||
{r.status && r.status !== "open" && (
|
||||
<span className="chip ml-auto shrink-0" style={{ borderColor: STATUS_COLOR[r.status], color: STATUS_COLOR[r.status] }}>{STATUS_LABEL[r.status]}</span>
|
||||
)}
|
||||
<span className={`chip shrink-0 ${r.status && r.status !== "open" ? "" : "ml-auto"}`}>{r.candidateCount} tools</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend assessed</div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Licence spend</div>
|
||||
<div className="tabnum text-[16px] font-semibold">{money(r.annualSpend, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Annual saving</div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Net annual benefit</div>
|
||||
<div className="tabnum text-[16px] font-semibold" style={{ color: "var(--success-text)" }}>{money(r.annualSavings, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
110
src/app/portfolio/page.tsx
Normal file
110
src/app/portfolio/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Portfolio } from "@/lib/portfolio";
|
||||
import { STATUS_LABEL } from "@/lib/portfolio";
|
||||
import { money } from "@/lib/format";
|
||||
import { ChartFrame, Empty, HBar, StatTile } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson } from "@/lib/client";
|
||||
|
||||
const STATUS_COLOR = { open: "var(--accent)", won: "var(--success-text)", paused: "var(--text-muted)", lost: "var(--critical)" } as const;
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const [p, setP] = useState<Portfolio | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => { getJson<Portfolio>("/api/portfolio").then(setP).catch((e) => setErr(errorMessage(e))); }, [attempt]);
|
||||
|
||||
if (err) return <LoadError message={err} onRetry={() => { setErr(""); setAttempt((n) => n + 1); }} />;
|
||||
if (!p) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading portfolio…</div>;
|
||||
|
||||
const t = p.totals;
|
||||
return (
|
||||
<div className="fade-up">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Portfolio</h1>
|
||||
<p className="mt-1 max-w-3xl text-[13.5px] text-[var(--text-secondary)]">
|
||||
Every assessment, valued as presented to its customer. Totals exclude lost deals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{p.rows.length === 0 ? <Empty>No assessments yet.</Empty> : (
|
||||
<>
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatTile label="Accounts in play" value={String(t.accounts)} sub={`${p.rows.length} assessments in total`} />
|
||||
<StatTile label="Net annual benefit" value={money(t.netAnnual, true)} tone="good" sub="At full run-rate, summed" />
|
||||
<StatTile label="Plan-adjusted net" value={money(t.horizonNet, true)} tone="accent" sub="Over each account's horizon" />
|
||||
<StatTile label="Cost in play" value={money(t.tco, true)} sub={`${t.tools} tools · ${t.vendors} vendor relationships`} />
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{p.byStatus.map((s) => (
|
||||
<div key={s.status} className="card flex items-center gap-3 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: STATUS_COLOR[s.status] }} />
|
||||
<span className="text-[13px] font-semibold">{STATUS_LABEL[s.status]}</span>
|
||||
<span className="ml-auto tabnum text-[12.5px] text-[var(--text-secondary)]">{s.accounts} · {money(s.netAnnual, true)}/yr</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-4 lg:grid-cols-2">
|
||||
<ChartFrame title="Value by business unit" subtitle="Net annual benefit across open, won and paused accounts."
|
||||
table={{ headers: ["Business unit", "Accounts", "Net annual benefit"], rows: p.byBu.map((b) => [b.name, b.accounts, money(b.savings)]) }}>
|
||||
{p.byBu.length ? <HBar data={p.byBu.map((b) => ({ label: b.name, value: b.savings, color: b.color, note: `${b.accounts} account${b.accounts === 1 ? "" : "s"}` }))} /> : <Empty />}
|
||||
</ChartFrame>
|
||||
|
||||
<section className="card p-5">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Most displaced competitors</h3>
|
||||
<p className="mb-3 mt-0.5 text-[12.5px] text-[var(--text-muted)]">Where enablement and battlecards pay off most.</p>
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead><tr className="border-b text-[var(--text-secondary)]">
|
||||
<th className="pb-2 text-left font-semibold">Competitor</th>
|
||||
<th className="pb-2 pl-3 text-right font-semibold">Accounts</th>
|
||||
<th className="pb-2 pl-3 text-right font-semibold">Current cost</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{p.competitors.map((c) => (
|
||||
<tr key={c.label} className="border-b border-[var(--grid)] last:border-0">
|
||||
<td className="py-1.5 font-medium">{c.label}</td>
|
||||
<td className="py-1.5 pl-3 text-right tabnum">{c.accounts}</td>
|
||||
<td className="py-1.5 pl-3 text-right tabnum">{money(c.tco, true)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card overflow-hidden">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="bg-[var(--surface-2)]">
|
||||
<tr className="border-b">
|
||||
{["Account", "Status", "Scenario", "Tools", "Current cost", "Net / yr", "Plan-adjusted net", "NPV"].map((h, i) => (
|
||||
<th key={h} className={`px-4 py-2.5 font-semibold ${i >= 3 ? "text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-[var(--grid)] last:border-0 hover:bg-[var(--surface-2)]">
|
||||
<td className="px-4 py-2.5 font-medium"><Link href={`/assessments/${r.id}`} className="hover:underline">{r.customerName}</Link></td>
|
||||
<td className="px-4 py-2.5"><span className="chip" style={{ borderColor: STATUS_COLOR[r.status], color: STATUS_COLOR[r.status] }}>{STATUS_LABEL[r.status]}</span></td>
|
||||
<td className="px-4 py-2.5 text-[var(--text-secondary)]">{r.scenario}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{r.toolsBefore} → {r.toolsAfter}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{money(r.tco, true)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum font-semibold" style={{ color: "var(--success-text)" }}>{money(r.netAnnual, true)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{money(r.horizonNet, true)} <span className="text-[11px] text-[var(--text-muted)]">/{r.horizonYears}y</span></td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{money(r.npv, true)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -329,7 +329,7 @@ function CategoryStep({
|
||||
<button key={o.id} onClick={() => toggle(o.label, o.id)} aria-pressed={on}
|
||||
className="rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors"
|
||||
style={on
|
||||
? { background: "var(--accent)", borderColor: "var(--accent)", color: "#fff" }
|
||||
? { background: "var(--accent)", borderColor: "var(--accent)", color: "var(--accent-ink)" }
|
||||
: { borderColor: "var(--border-strong)", color: "var(--text-primary)" }}>
|
||||
{on ? "✓ " : ""}{o.label}
|
||||
</button>
|
||||
|
||||
65
src/app/settings/page.tsx
Normal file
65
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Branding } from "@/lib/types";
|
||||
import { BRAND_SWATCHES } from "@/lib/brand";
|
||||
import { LogoInput } from "@/components/LogoInput";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [b, setB] = useState<Branding | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
useEffect(() => { getJson<Branding>("/api/settings").then(setB).catch((e) => setLoadError(errorMessage(e))); }, []);
|
||||
const set = (patch: Partial<Branding>) => { setB((prev) => (prev ? { ...prev, ...patch } : prev)); setDirty(true); setErr(""); };
|
||||
|
||||
async function save() {
|
||||
if (!b) return;
|
||||
setSaving(true);
|
||||
try { setB(await sendJson<Branding>("/api/settings", "PUT", b)); setDirty(false); }
|
||||
catch (e) { setErr(errorMessage(e)); } finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => location.reload()} />;
|
||||
if (!b) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading settings…</div>;
|
||||
|
||||
return (
|
||||
<div className="fade-up max-w-[760px]">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Settings</h1>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--text-secondary)]">How “us” appears on customer reports, Word files and decks.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary ml-auto" onClick={save} disabled={!dirty || saving}>
|
||||
{saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
|
||||
|
||||
<section className="card space-y-5 p-5">
|
||||
<div>
|
||||
<label className="label" htmlFor="st-name">Company name</label>
|
||||
<input id="st-name" className="field max-w-[360px]" placeholder="e.g. Broadcom" value={b.companyName} onChange={(e) => set({ companyName: e.target.value })} />
|
||||
</div>
|
||||
<LogoInput label="Our logo (PNG or JPEG, under 600 KB — use a version you are authorised to use)" value={b.logo} onChange={(logo) => set({ logo })} />
|
||||
<div>
|
||||
<span className="label">Accent colour</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{BRAND_SWATCHES.map((s) => (
|
||||
<button key={s.hex} onClick={() => set({ accent: s.hex })} title={`${s.name} ${s.hex}`}
|
||||
className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[12px]"
|
||||
style={b.accent.toLowerCase() === s.hex.toLowerCase() ? { borderColor: "var(--text-primary)", boxShadow: "0 0 0 1px var(--text-primary)" } : undefined}>
|
||||
<span className="h-4 w-4 rounded" style={{ background: s.hex }} />{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--text-muted)]">Brand palette only. Used for headings and highlights on reports and decks.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
27
src/lib/brand.ts
Normal file
27
src/lib/brand.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* VMware by Broadcom palette (Brand Guidelines, "Colors & Design Components").
|
||||
* Light-theme values are the AA-safe ones for text on white; dark-theme values
|
||||
* are the guide's adjusted set for dark backgrounds.
|
||||
*/
|
||||
export const BRAND = {
|
||||
darkBlue: "#1B1D36",
|
||||
navy: "#005C8A",
|
||||
lightBlue: "#007DA3",
|
||||
azure: "#0098C7",
|
||||
aqua: "#007B8C",
|
||||
green: "#61A60E",
|
||||
greenAA: "#23800A",
|
||||
purple: "#6C4B94",
|
||||
} as const;
|
||||
|
||||
/** Swatches offered wherever a colour is picked (accent, business units). */
|
||||
export const BRAND_SWATCHES: { name: string; hex: string }[] = [
|
||||
{ name: "Navy", hex: BRAND.navy },
|
||||
{ name: "Light Blue", hex: BRAND.lightBlue },
|
||||
{ name: "Azure", hex: BRAND.azure },
|
||||
{ name: "Aqua", hex: BRAND.aqua },
|
||||
{ name: "Green", hex: BRAND.greenAA },
|
||||
{ name: "Lime", hex: BRAND.green },
|
||||
{ name: "Purple", hex: BRAND.purple },
|
||||
{ name: "Dark Blue", hex: BRAND.darkBlue },
|
||||
];
|
||||
60
src/lib/deliverable.ts
Normal file
60
src/lib/deliverable.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Assessment, Catalog } from "./types";
|
||||
import { type Evaluation, evaluate, findScenario } from "./finance";
|
||||
import { money } from "./format";
|
||||
import { monthLabel } from "./timeline";
|
||||
|
||||
/**
|
||||
* Shared content for customer deliverables (Word, deck). Takes the redacted
|
||||
* assessment, so nothing internal can reach a file, and presents the scenario
|
||||
* the seller chose. Figures are net: our investment is never itemized.
|
||||
*/
|
||||
export function deliverable(a: Assessment, catalog: Catalog) {
|
||||
const e = evaluate(a, catalog, findScenario(a, a.presentedScenario));
|
||||
return { e, summary: summaryText(e), nextSteps: nextSteps(e) };
|
||||
}
|
||||
|
||||
export function summaryText(e: Evaluation) {
|
||||
const { a, r, cash } = e;
|
||||
if (a.executiveSummary.trim()) return a.executiveSummary.trim();
|
||||
// Only claim a smaller estate when the mapping actually produces one; otherwise
|
||||
// the consolidation story is vendors, not tool count.
|
||||
const footprint = r.toolsAfter < r.toolsRetired
|
||||
? `reduces the estate from ${r.toolsRetired} tools to ${r.toolsAfter}`
|
||||
: `brings ${r.vendorsRetired} vendor relationships down to ${r.vendorsAfter}`;
|
||||
return (
|
||||
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ` +
|
||||
`${money(r.addressableTco)} of annual cost of ownership and ${footprint}. ` +
|
||||
`Once every migration is complete the net benefit is ${money(cash.runRateNet)} a year. Across the ${a.horizonYears}-year plan, ` +
|
||||
`with savings arriving as each tool is retired, the net benefit is ${money(cash.horizonNet)} (NPV ${money(cash.npv)})` +
|
||||
`${cash.paybackMonth ? `, paying back in month ${cash.paybackMonth}` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Concrete next steps drawn from the plan, soonest first. */
|
||||
export function nextSteps(e: Evaluation): string[] {
|
||||
const { plan, r } = e;
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
const steps: string[] = [];
|
||||
|
||||
const first = plan.items.filter((i) => i.start < 3);
|
||||
if (first.length) {
|
||||
steps.push(`Start the first ${first.length} migration${first.length === 1 ? "" : "s"} by ${L(2)}: ${first.slice(0, 5).map((i) => i.label).join(", ")}${first.length > 5 ? " and more" : ""}.`);
|
||||
}
|
||||
const bridges = plan.items.filter((i) => i.bridge);
|
||||
if (bridges.length) {
|
||||
steps.push(`Negotiate short extensions for ${bridges.map((i) => `${i.label} (ends ${L(i.contractEnd!)})`).join(", ")} — the contract ends before migration can finish.`);
|
||||
}
|
||||
const soon = plan.items.filter((i) => i.contractEnd !== null && !i.bridge && i.contractEnd < 12);
|
||||
if (soon.length) {
|
||||
steps.push(`Give notice on contracts ending in the next 12 months: ${soon.map((i) => `${i.label} (${L(i.contractEnd!)})`).join(", ")}.`);
|
||||
}
|
||||
const noSpend = r.included.filter((c) => c.annualSpend <= 0).length;
|
||||
if (noSpend) {
|
||||
steps.push(`Confirm current spend for ${noSpend} tool${noSpend === 1 ? "" : "s"} with no cost captured yet — they are valued at zero today.`);
|
||||
}
|
||||
if (r.unmappedCount) {
|
||||
steps.push(`Agree a replacement for ${r.unmappedCount} unmapped tool${r.unmappedCount === 1 ? "" : "s"} so they can join the plan.`);
|
||||
}
|
||||
steps.push("Appoint an executive sponsor and a program lead, and confirm the sequence in a joint planning workshop.");
|
||||
return steps;
|
||||
}
|
||||
@@ -298,6 +298,8 @@ const tokens = (s: string) => nameKey(s.replace(/\(.*?\)/g, " ")).split(" ").fil
|
||||
|
||||
export type DuplicatePair = { a: KbEntry; b: KbEntry; reason: string };
|
||||
|
||||
const sameTarget = (a: KbEntry, b: KbEntry) => a.productIds.some((id) => b.productIds.includes(id));
|
||||
|
||||
export const pairKey = (x: string, y: string) => [x, y].sort().join("|");
|
||||
|
||||
/**
|
||||
@@ -308,7 +310,10 @@ export const pairKey = (x: string, y: string) => [x, y].sort().join("|");
|
||||
export function findDuplicates(kb: KnowledgeBase): DuplicatePair[] {
|
||||
const distinct = new Set(kb.distinctPairs ?? []);
|
||||
const out: DuplicatePair[] = [];
|
||||
const list = kb.entries.map((e) => ({ e, t: tokens(e.label), names: new Set([e.label, ...e.aliases].map(nameKey)) }));
|
||||
const list = kb.entries.map((e) => ({
|
||||
e, t: tokens(e.label), raw: nameKey(e.label.replace(/\(.*?\)/g, " ")).split(" ").filter(Boolean),
|
||||
names: new Set([e.label, ...e.aliases].map(nameKey)),
|
||||
}));
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const x = list[i], y = list[j];
|
||||
@@ -317,9 +322,12 @@ export function findDuplicates(kb: KnowledgeBase): DuplicatePair[] {
|
||||
let reason = "";
|
||||
if (x.t.length && x.t.join(" ") === y.t.join(" ")) reason = "Same name once generic words are ignored";
|
||||
else if ([...x.names].some((n) => y.names.has(n))) reason = "Share a name or alias";
|
||||
else if (x.t[0] && x.t[0] === y.t[0] && x.t.length !== y.t.length) {
|
||||
const [short, long] = x.t.length < y.t.length ? [x.t, y.t] : [y.t, x.t];
|
||||
if (short.every((t, k) => long[k] === t)) reason = "One name extends the other";
|
||||
else if (x.raw.length !== y.raw.length && sameTarget(x.e, y.e)) {
|
||||
// Product families ("Datadog" / "Datadog Logs") are usually distinct
|
||||
// modules, so a longer name only counts as a duplicate when it also
|
||||
// lands on the same replacement.
|
||||
const [short, long] = x.raw.length < y.raw.length ? [x.raw, y.raw] : [y.raw, x.raw];
|
||||
if (short.every((t, k) => long[k] === t)) reason = "One name extends the other, same replacement";
|
||||
}
|
||||
if (reason) out.push({ a: x.e, b: y.e, reason });
|
||||
}
|
||||
|
||||
@@ -9,9 +9,19 @@ import type { Assessment } from "./types";
|
||||
* they are removed at the boundary rather than merely hidden in the view.
|
||||
*/
|
||||
export function customerSafe(a: Assessment): Assessment {
|
||||
// Entitlement flags name dossier products. Where an investment is recorded the
|
||||
// flag's only financial effect is "no subscription share", which an explicit
|
||||
// zero price preserves; without one the flag changes nothing, so it just goes.
|
||||
const invested = (a.finance?.investment?.annual ?? 0) > 0 || (a.finance?.investment?.services ?? 0) > 0
|
||||
|| a.candidates.some((c) => c.investment?.annual != null || c.investment?.services != null);
|
||||
return {
|
||||
...a,
|
||||
accountIntel: null,
|
||||
candidates: a.candidates.map((c) => ({ ...c, notes: "" })),
|
||||
candidates: a.candidates.map(({ alreadyEntitled, ...c }) => ({
|
||||
...c,
|
||||
notes: "",
|
||||
...(invested && alreadyEntitled && c.investment?.annual == null
|
||||
? { investment: { annual: 0, services: c.investment?.services ?? null } } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user