diff --git a/data/catalog.json b/data/catalog.json index 4f70d70..3af0ef1 100644 --- a/data/catalog.json +++ b/data/catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-09-23", + "updatedAt": "2026-09-24", "source": [ "https://www.vmware.com/products", "https://www.broadcom.com/products/enterprise-software", @@ -12,49 +12,49 @@ "id": "vcf", "name": "VCF Division", "short": "VCF", - "color": "#2a78d6" + "color": "#005C8A" }, { "id": "tanzu", "name": "Tanzu Division", "short": "Tanzu", - "color": "#1baf7a" + "color": "#61A60E" }, { "id": "ans", "name": "Application Networking & Security", "short": "ANS", - "color": "#4a3aa7" + "color": "#6C4B94" }, { "id": "aod", "name": "Agile Operations Division", "short": "AOD", - "color": "#eb6834" + "color": "#0098C7" }, { "id": "esg", "name": "Enterprise Security Group", "short": "ESG", - "color": "#e34948" + "color": "#007B8C" }, { "id": "cb", "name": "Carbon Black", "short": "CB", - "color": "#e87ba4" + "color": "#007DA3" }, { "id": "msd", "name": "Mainframe Software Division", "short": "MSD", - "color": "#eda100" + "color": "#A468EE" }, { "id": "ims", "name": "IMS", "short": "IMS", - "color": "#898781" + "color": "#23800A" } ], "domains": [ @@ -883,7 +883,7 @@ { "id": "aod-dx-apm", "name": "DX Application Performance Management", - "bu": "aod", + "bu": "ims", "domain": "observability", "description": "Full-stack APM with distributed tracing, code-level diagnostics and business transaction analytics for enterprise and mainframe-connected apps.", "replaces": [ @@ -900,7 +900,7 @@ { "id": "aod-dx-oi", "name": "DX Operational Intelligence", - "bu": "aod", + "bu": "ims", "domain": "observability", "description": "AIOps correlation, anomaly detection and noise reduction across monitoring, ticketing and event sources.", "replaces": [ @@ -917,7 +917,7 @@ { "id": "aod-dx-uim", "name": "DX Unified Infrastructure Management", - "bu": "aod", + "bu": "ims", "domain": "observability", "description": "Unified monitoring for servers, storage, network, databases, virtualization and public cloud from one console.", "replaces": [ @@ -935,7 +935,7 @@ { "id": "aod-dx-uim-msp", "name": "DX UIM for Managed Service Providers", - "bu": "aod", + "bu": "ims", "domain": "observability", "description": "Multi-tenant infrastructure monitoring packaged for MSP delivery and per-tenant billing.", "replaces": [ @@ -999,7 +999,7 @@ { "id": "aod-layer7-gateway", "name": "Layer7 API Gateway", - "bu": "aod", + "bu": "ims", "domain": "api", "description": "High-performance API gateway for policy enforcement, mediation, throttling and protocol translation at enterprise scale.", "replaces": [ @@ -1017,7 +1017,7 @@ { "id": "aod-layer7-portal", "name": "Layer7 API Developer Portal", - "bu": "aod", + "bu": "ims", "domain": "api", "description": "Developer onboarding, API catalog, documentation and key management portal.", "replaces": [ @@ -1033,7 +1033,7 @@ { "id": "aod-layer7-apisec", "name": "Layer7 API Security", - "bu": "aod", + "bu": "ims", "domain": "api", "description": "API discovery, posture management, threat protection and runtime API attack detection.", "replaces": [ @@ -1050,7 +1050,7 @@ { "id": "aod-cloudhealth", "name": "CloudHealth", - "bu": "aod", + "bu": "ims", "domain": "finops", "description": "Multicloud FinOps: cost visibility, showback/chargeback, rightsizing, commitment management and governance policies.", "replaces": [ @@ -1067,7 +1067,7 @@ { "id": "aod-cloudhealth-partner", "name": "CloudHealth Partner Platform", - "bu": "aod", + "bu": "ims", "domain": "finops", "description": "Multi-tenant FinOps and billing platform for cloud resellers and managed service providers.", "replaces": [ @@ -1247,7 +1247,7 @@ { "id": "aod-sdm", "name": "Service Desk Manager", - "bu": "aod", + "bu": "ims", "domain": "itsm", "description": "ITIL-aligned service desk: incident, problem, change, knowledge and CMDB.", "replaces": [ diff --git a/data/knowledge-base.json b/data/knowledge-base.json index 69c2d1e..11a8d53 100644 --- a/data/knowledge-base.json +++ b/data/knowledge-base.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-09-23T21:02:11.252Z", + "updatedAt": "2026-09-24T19:40:52.581Z", "entries": [ { "id": "389-directory-server", @@ -2623,7 +2623,7 @@ } ], "createdAt": "2026-09-23T12:58:32.707Z", - "updatedAt": "2026-09-23T21:02:11.250Z" + "updatedAt": "2026-09-24T19:40:52.581Z" }, { "id": "cisco-nexus-dashboard-insights", @@ -5278,7 +5278,7 @@ } ], "createdAt": "2026-09-23T12:58:32.807Z", - "updatedAt": "2026-09-23T21:02:11.250Z" + "updatedAt": "2026-09-24T19:40:52.581Z" }, { "id": "f5-ssl-orchestrator", @@ -6247,7 +6247,7 @@ "origin": "catalog", "observations": [], "createdAt": "2026-09-23T12:58:32.707Z", - "updatedAt": "2026-09-23T12:58:32.707Z" + "updatedAt": "2026-09-24T19:40:52.581Z" }, { "id": "ibm-application-performance-analyzer", diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 0000000..c0d3bdc --- /dev/null +++ b/data/settings.json @@ -0,0 +1,5 @@ +{ + "companyName": "Broadcom", + "accent": "#005C8A", + "logo": "data:image/png;base64,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" +} diff --git a/scripts/test-finance.mjs b/scripts/test-finance.mjs index 61504f7..34e04f8 100644 --- a/scripts/test-finance.mjs +++ b/scripts/test-finance.mjs @@ -111,9 +111,12 @@ console.log('Shelfware'); console.log('Knowledge base curation'); { const kb = { version: 1, updatedAt: '', entries: [newEntry('F5 BIG-IP', 'manual'), newEntry('F5 BIG-IP LTM', 'manual'), newEntry('Microsoft Entra ID', 'manual')] }; - kb.entries.forEach((e) => (e.domain = 'adc')); + kb.entries.forEach((e) => { e.domain = 'adc'; e.productIds = ['ans-avi']; }); const dups = findDuplicates(kb); t('finds F5 BIG-IP / F5 BIG-IP LTM', dups.some((d) => [d.a.label, d.b.label].sort().join('|') === 'F5 BIG-IP|F5 BIG-IP LTM')); + const fam = { entries: [newEntry('Datadog', 'manual'), newEntry('Datadog Logs', 'manual')] }; + fam.entries[0].productIds = ['aod-dx-oi']; fam.entries[1].productIds = ['vcf-ops-logs']; + t('product family with different replacements is not a duplicate', findDuplicates(fam).length === 0); const a = base([line('f5', { incumbentVendor: 'F5', incumbentProduct: 'BIG-IP', domain: 'adc', productIds: ['ans-avi'] })]); const learned = learnFromAssessment(kb, a); const merged = mergeEntries(learned, 'f5-big-ip-ltm', 'f5-big-ip'); diff --git a/scripts/test-report-leak.mjs b/scripts/test-report-leak.mjs index 201019d..c91d901 100644 --- a/scripts/test-report-leak.mjs +++ b/scripts/test-report-leak.mjs @@ -71,6 +71,33 @@ try { t('Word still carries a mapped product', /Avi Load Balancer|VCF Networking|Layer7/.test(docx)); execFileSync('rm', ['-f', tmp.pathname]); + + /* ---- our pricing and dossier entitlements stay internal (sprint 2) ---- */ + const PRICE = 7_654_321, SERVICES = 1_234_567; + const fmt = (n) => '$' + n.toLocaleString('en-US'); + const priced = await json(`/api/assessments/${a.id}`); + priced.finance = { discountRate: 10, fteCost: 150000, investment: { annual: PRICE, services: SERVICES } }; + priced.candidates[0].alreadyEntitled = 'VMware Avi Load Balancer'; + await send(`/api/assessments/${a.id}`, 'PUT', priced); + + const deckBuf = Buffer.from(await (await fetch(`${B}/api/assessments/${a.id}/deck`)).arrayBuffer()); + const deckTmp = new URL('./.leak-test.pptx', import.meta.url); + writeFileSync(deckTmp, deckBuf); + const deck = execFileSync('sh', ['-c', `unzip -p '${deckTmp.pathname}' 'ppt/slides/*.xml'`], { maxBuffer: 64e6 }).toString('utf8'); + t('deck is a readable pptx with slides', deck.includes(' 5000); + const docx2Buf = Buffer.from(await (await fetch(`${B}/api/assessments/${a.id}/docx`)).arrayBuffer()); + writeFileSync(tmp, docx2Buf); + const docx2 = execFileSync('unzip', ['-p', tmp.pathname, 'word/document.xml'], { maxBuffer: 32e6 }).toString('utf8'); + const safe2 = JSON.stringify(await json(`/api/assessments/${a.id}/report`)); + + for (const [name, text] of [['deck', deck], ['Word', docx2]]) { + t(`${name} omits our annual price`, !text.includes(fmt(PRICE)) && !text.includes('7.7M')); + t(`${name} omits our services figure`, !text.includes(fmt(SERVICES))); + t(`${name} never says "subscription"`, !/subscription/i.test(text)); + t(`${name} never names an entitlement`, !/entitle/i.test(text)); + } + t('projection drops entitlement flags', !safe2.includes('alreadyEntitled')); + execFileSync('rm', ['-f', tmp.pathname, deckTmp.pathname]); } finally { await fetch(`${B}/api/assessments/${a.id}`, { method: 'DELETE' }); } diff --git a/src/app/api/assessments/[id]/deck/route.ts b/src/app/api/assessments/[id]/deck/route.ts new file mode 100644 index 0000000..398088c --- /dev/null +++ b/src/app/api/assessments/[id]/deck/route.ts @@ -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"`, + }, + }); +} diff --git a/src/app/api/assessments/[id]/docx/route.ts b/src/app/api/assessments/[id]/docx/route.ts index 106ba01..37f55e0 100644 --- a/src/app/api/assessments/[id]/docx/route.ts +++ b/src/app/api/assessments/[id]/docx/route.ts @@ -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", diff --git a/src/app/assessments/[id]/page.tsx b/src/app/assessments/[id]/page.tsx index 892376e..23f1b01 100644 --- a/src/app/assessments/[id]/page.tsx +++ b/src/app/assessments/[id]/page.tsx @@ -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([]); const [showSurveys, setShowSurveys] = useState(false); const [reviewing, setReviewing] = useState(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 }> ); } - if (!a || !catalog || !r) return
Loading…
; + if (!a || !catalog || !r || !ev) return
Loading…
; 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 }>
{statusText} + + Word + Deck Customer report →
@@ -267,6 +314,10 @@ export default function Workbench({ params }: { params: Promise<{ id: string }> update((d) => { d[k] = e.target.value; })} /> ))} +
+ update((d) => { if (v) d.customerLogo = v; else delete d.customerLogo; })} /> +