From 93007a1053c80fd06685b76a6e37a70ef5d71990 Mon Sep 17 00:00:00 2001 From: Chris Olson Date: Thu, 24 Sep 2026 15:41:15 -0400 Subject: [PATCH] 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 --- data/catalog.json | 38 +-- data/knowledge-base.json | 8 +- data/settings.json | 5 + scripts/test-finance.mjs | 5 +- scripts/test-report-leak.mjs | 27 ++ src/app/api/assessments/[id]/deck/route.ts | 243 ++++++++++++++ src/app/api/assessments/[id]/docx/route.ts | 133 +++++--- src/app/assessments/[id]/page.tsx | 90 +++++- src/app/assessments/[id]/report/page.tsx | 158 ++++----- src/app/catalog/page.tsx | 30 +- src/app/globals.css | 150 +++++---- src/app/icon.svg | 4 +- src/app/knowledge-base/page.tsx | 112 ++++++- src/app/page.tsx | 14 +- src/app/portfolio/page.tsx | 110 +++++++ src/app/q/[token]/page.tsx | 2 +- src/app/settings/page.tsx | 65 ++++ src/components/Analysis.tsx | 230 ++++++++++---- src/components/CandidateRow.tsx | 73 ++++- src/components/Finance.tsx | 353 +++++++++++++++++++++ src/components/Logo.tsx | 7 +- src/components/LogoInput.tsx | 37 +++ src/components/Nav.tsx | 2 + src/components/Overlap.tsx | 80 +++++ src/components/Shelfware.tsx | 64 ++++ src/components/Snapshots.tsx | 162 ++++++++++ src/lib/brand.ts | 27 ++ src/lib/deliverable.ts | 60 ++++ src/lib/kb.ts | 16 +- src/lib/redact.ts | 12 +- 30 files changed, 2005 insertions(+), 312 deletions(-) create mode 100644 data/settings.json create mode 100644 src/app/api/assessments/[id]/deck/route.ts create mode 100644 src/app/portfolio/page.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/components/Finance.tsx create mode 100644 src/components/LogoInput.tsx create mode 100644 src/components/Overlap.tsx create mode 100644 src/components/Shelfware.tsx create mode 100644 src/components/Snapshots.tsx create mode 100644 src/lib/brand.ts create mode 100644 src/lib/deliverable.ts 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; })} /> +