Compare commits

3 Commits

Author SHA1 Message Date
6f42b0274d Catalog: move ITSM and endpoint products to the IMS business unit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:53:12 -04:00
93007a1053 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>
2026-09-24 15:41:15 -04:00
4496312845 Sprint 2 engine: TCO, investment, ramp-aware cash flow, scenarios, planner overrides, shelfware, KB curation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:20:36 -04:00
47 changed files with 3249 additions and 459 deletions

BIN
Brand Guidelines.pdf Normal file

Binary file not shown.

View File

@@ -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": [
@@ -1265,7 +1265,7 @@
{
"id": "aod-service-catalog",
"name": "Service Catalog",
"bu": "aod",
"bu": "ims",
"domain": "itsm",
"description": "Self-service request catalog with approval workflow and fulfillment orchestration.",
"replaces": [
@@ -1280,7 +1280,7 @@
{
"id": "aod-apm-asset",
"name": "Asset Portfolio Management",
"bu": "aod",
"bu": "ims",
"domain": "itsm",
"description": "IT asset and software license lifecycle management from procurement to disposal.",
"replaces": [
@@ -1296,7 +1296,7 @@
{
"id": "aod-client-automation",
"name": "Client Automation",
"bu": "aod",
"bu": "ims",
"domain": "itsm",
"description": "Endpoint discovery, software delivery, patch and OS deployment for managed desktops and servers.",
"replaces": [
@@ -1313,7 +1313,7 @@
{
"id": "aod-itpam",
"name": "IT Process Automation Manager",
"bu": "aod",
"bu": "ims",
"domain": "itsm",
"description": "Low-code runbook and IT process automation engine for remediation and fulfillment workflows.",
"replaces": [
@@ -1329,7 +1329,7 @@
{
"id": "aod-bsi",
"name": "Business Service Insight",
"bu": "aod",
"bu": "ims",
"domain": "itsm",
"description": "SLA definition, measurement and reporting across internal and outsourced service providers.",
"replaces": [
@@ -1344,7 +1344,7 @@
{
"id": "aod-service-virtualization",
"name": "Service Virtualization",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Simulates unavailable or costly dependent services so teams can test continuously and in parallel.",
"replaces": [
@@ -1360,7 +1360,7 @@
{
"id": "aod-tdm",
"name": "Test Data Manager",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Synthetic and masked test data generation with privacy compliance for non-production environments.",
"replaces": [
@@ -1377,7 +1377,7 @@
{
"id": "aod-ard",
"name": "Agile Requirements Designer",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Model-based requirements and automated test-case generation for maximum coverage with fewer tests.",
"replaces": [
@@ -1392,7 +1392,7 @@
{
"id": "aod-harvest",
"name": "Harvest Software Change Manager",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Enterprise source control and change management with audited promotion across environments.",
"replaces": [
@@ -1408,7 +1408,7 @@
{
"id": "aod-nolio",
"name": "Nolio Release Automation",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Application release orchestration across environments with reusable deployment models.",
"replaces": [
@@ -1425,7 +1425,7 @@
{
"id": "aod-2e",
"name": "CA 2E",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Model-driven development for IBM i applications with automated code generation.",
"replaces": [
@@ -1440,7 +1440,7 @@
{
"id": "aod-plex",
"name": "CA Plex",
"bu": "aod",
"bu": "ims",
"domain": "devops",
"description": "Model-driven multi-platform application development generating Java, .NET and IBM i code.",
"replaces": [
@@ -1519,7 +1519,7 @@
{
"id": "esg-itms",
"name": "Symantec IT Management Suite",
"bu": "esg",
"bu": "ims",
"domain": "itsm",
"description": "Unified endpoint discovery, inventory, software delivery, patching and imaging across Windows, macOS and Linux.",
"replaces": [
@@ -1536,7 +1536,7 @@
{
"id": "esg-cms",
"name": "Symantec Client Management Suite",
"bu": "esg",
"bu": "ims",
"domain": "itsm",
"description": "Desktop lifecycle management: inventory, patch, software delivery and remote control.",
"replaces": [
@@ -1552,7 +1552,7 @@
{
"id": "esg-sms",
"name": "Symantec Server Management Suite",
"bu": "esg",
"bu": "ims",
"domain": "itsm",
"description": "Server provisioning, patch, monitoring and compliance reporting for datacenter estates.",
"replaces": [
@@ -1568,7 +1568,7 @@
{
"id": "esg-ams",
"name": "Symantec Asset Management Suite",
"bu": "esg",
"bu": "ims",
"domain": "itsm",
"description": "Hardware and software asset lifecycle, license compliance and contract management.",
"replaces": [
@@ -1584,7 +1584,7 @@
{
"id": "esg-ghost",
"name": "Symantec Ghost Solution Suite",
"bu": "esg",
"bu": "ims",
"domain": "itsm",
"description": "OS imaging, deployment and migration for large endpoint fleets.",
"replaces": [
@@ -1862,7 +1862,7 @@
{
"id": "esg-email-gateway",
"name": "Symantec Messaging Gateway",
"bu": "esg",
"bu": "ims",
"domain": "email-sec",
"description": "On-premises email security gateway with content filtering, encryption and DLP integration.",
"replaces": [
@@ -1894,7 +1894,7 @@
{
"id": "esg-siteminder",
"name": "Symantec SiteMinder",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Web access management and SSO/federation for large-scale web and API estates.",
"replaces": [
@@ -1912,7 +1912,7 @@
{
"id": "esg-vip",
"name": "Symantec VIP Authentication",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Cloud-based multi-factor authentication, passwordless and risk-based access with a broad authenticator ecosystem.",
"replaces": [
@@ -1929,7 +1929,7 @@
{
"id": "esg-iga",
"name": "Symantec Identity Governance & Administration",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Identity lifecycle, access certification, role mining and segregation-of-duties enforcement.",
"replaces": [
@@ -1946,7 +1946,7 @@
{
"id": "esg-pam",
"name": "Symantec Privileged Access Management",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Credential vaulting, session recording and least-privilege enforcement for privileged accounts.",
"replaces": [
@@ -1963,7 +1963,7 @@
{
"id": "esg-directory",
"name": "Symantec Directory",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "High-performance LDAP/X.500 directory for large identity populations and telco-scale lookups.",
"replaces": [
@@ -1980,7 +1980,7 @@
{
"id": "esg-agentminder",
"name": "Symantec AgentMinder",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Agent and policy-server management tooling for large SiteMinder deployments.",
"replaces": [
@@ -1994,7 +1994,7 @@
{
"id": "esg-identity-platform",
"name": "Symantec Identity Security Platform",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Converged identity platform unifying IGA, PAM, access management and MFA under shared policy and analytics.",
"replaces": [
@@ -2010,7 +2010,7 @@
{
"id": "esg-identity-vcf",
"name": "Symantec Identity Security for VCF",
"bu": "esg",
"bu": "ims",
"domain": "iam",
"description": "Identity security packaged and validated for VMware Cloud Foundation private cloud estates.",
"replaces": [

View File

@@ -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",

5
data/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"companyName": "Broadcom",
"accent": "#005C8A",
"logo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQoAAAC+CAMAAAD6ObEsAAAAzFBMVEX///8jHyDlFjYAAAAJAAAgHB0SCw3x8fG5uLjh4eEYEhTp6ekQCAr4+PkeGhuXlpaLiorkACDV1dVsamusq6vkACqysbEXERPkACvr6+vjABt1c3SRkJDkACQMAgY4NTbJyMjwjJebmpqmpaXtcoBCP0DufoqDgoJIRUYsKCn2vcPMy8v97/HZ2NjjABX62d3xmKJdW1xST1D0s7n75ed6eHn3xsznLUjpQlnqVmjym6ViYGHmIj/oPVIxLi/1tb3rZnTiAADnKkXqU2WxJLwOAAAIk0lEQVR4nO2aaUPbuhKGHWQ7ieM4C9lJnBCWAFkoYbmUAOW0//8/Hc1IliUSWrj9wL3t+3zocWRbGr2aGY3M8TwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD+S6mKx+Gwb/he4O/1Prz1q9+6/XH62KZ9K9XCv0dtTtBtP159tz+dx3W7v2TSe/lbP+NrYe83g4bON+gyqT+0tJaQWh85DlZy+9aphR7eT2rBem2zf2X7+J70wzaPptFPeai7KEYZTe4TqK/u8PjXwVedo8kbvFve9HUpILb5Yz1SEhb8uaVuEb4g2zlCVfSHiMIyF2K+4w5XpJdG1m9KsnzRZ115PuXzrCz9OfVGYuu2bbIQDM/kO25cP2OTf1OO0WZqXfqXE8Sif/ujeCpXBhS1FwSISV0qKbmDaAtmYr09NdLMbXTF0xpuF1CrmVlNgugmSWGwc7eoiGyTwo5LdHuUjHOnGEpkZXZmHThIaiqRoterNI+/nXKwsT1h4F3bayKfGUgQJwYaJGUsR5VJIwrPs8Y1STk9RbOwBBTd293dKoWbWMneqa92T+jdX9cpuL4gDS4qCyBQ7Uj9JilppUnG9apvHfOZtCokny0fydEFSBM8n4/H45Fyw1P1MilBFDa210INdpfJH4oswFD49nObrxNbSBIQV4iRFpLqJA6sfzzujbhMhukKkfKem2texGqGrR9BaKCmCRPVd9AMjRXnYGb6VijTfLC9o0A56amWOnnmZpPA76rpPbsfKkxShWqjKzKcp5ouRjjtF+URnTLKI3DfXUSEYS9/286X35IsRe0mxOfUjK3yo04JYl6Qd/aNIxly85vYajeCf0Aj91jPJIjq5FIWYfdbbV0EqthPuTu6teGhT1X1tpw5TarEUmfGT7AdLUdet+5EetUghILJmb0bmiaL+1Ze/0tYmKiTjHVJ4FBJyAvrmUtgyVjdpuDbWFPJguRWZJ2gpCmKpzfyAFAs7UzxSy83A8oqvO6UgC3OvyOZckisoJvJiKP0gneVjHEgnT7Vje7WYdCGT2dxtKTxvnOiOeFmtxOGtdTKizOtbyXg/1IqxFHIhkhP54zzRofg+KS4sH+gdc5PVkidOO0C8WVcv824pzgNnyT1PZlodOnydyKWV3YW5Wq4Uc3mzeysvqvIislNuX5lTpRFOrPYq9XqmpQhYyiNvKO2JxsG7pTi0qquRLDBvPO+HlSwaN5YU3VmJaG38zDutXCFzpQ6QsnATgdzTpZvozX6uJT0gNc0DgSOF9yyle/aUhzubrsZ4pWEYqsVhb5vI7oKQRkrWnfTdUhzb85ZZ84ubNxt3lhRyl+MCK8r2UpaiO6OKrjy/inWI8wSc0oBNn/AlJ5SqkiSfjesVpFMQyYeOUnljR9rnCRftlrkWTQUe+wYn3ErHf7cUL/Z+sfCqe3JPsfNmVmW5JVaiMxlvpkogLng4jfPoziBFP4sudnmedKoCRfHKK+qUXCr838CJNE3LlxWLI1FfK8tSNL2N3jpq9Oh/I8V3z7tbVZ286UoRpUQsx/HHRSNFjtrcWQpnzXIpWlk+4eka39klRV9Jcb5bikLsjPBKClVQkI9+QAorQHqnnvfwjwyStwIkOjgiprR3dce5FIEqFhNd/syVORZNEyDPifRcbqOMEmfbyisp5FbLeyPNI7UPVxqesDM/J0CadBbRO+oHpLDSJhURLwP5z9dcCydtmh2Es0Enk0IqkcjoSLKqm1bIbCtMXSc1ThrRQb8sKcpdIIj0A26uKArd23wrAefGxM7B5qBrpU1aBpnDebf9gBQXq0HvcTDKsuZj+1TL02s0vjcGPWczNVbJcpM3ObODcAEwz++qutyeGu99fBLTFTbv+BP1hOsV9awOqcqE4kTIfGJGCLpWhJCLRZR5jBR9IWgP+ogUi0OqMC+eZPnd5oJLJgw6kQ2OqQi/NIcQV4p1opbN1BV029QSLTLsLB+DpeF3ndxbKJj5O1JwmaTSyK0UJbTcJdFl2dTXU1dUx9lJwEjhzev9D0qRcd1efeWCSxbf1cZq63ueEyBLv6DSf15izWKrQqYyrzvWBpTH3exwUnotRZZebSnqfICZmVEL/pVe/4k8Ffvca5WyU3ims27zPMoWovQ6T31MiotvnBAoQcokShljwUJcXt/lD3GJddth6nR6DG8dKapd6cu+tpliXGbRg8ly2dqocyybt6ZK2HwAomOar9Qr0M5akl1PDwSdraJnPeyUNy4xo57OKKYSdaCdqBFuJ8tmaaNibfn7UtyvRqtTdXnHBxL1l5CbH7L9xZGi0FWfmkJz4LEKbzqNhvqrgf5OQAVHyt9XVHiU2fNNl7SvJGrOtMgJdR1zQRCemzywzz3RdwA+iwdZWTY1I3BBE6iD6W9KcSnn3+493HiLB1VQPF5UvcvTQc/aP16XWNk3FPsMch5YVrTYF7KCTNs5jPXZQhOYN5xPN5HYt8qnWxFs98RqJ/YrWqHfDJBD2j/ag9Ug21dHqxX/TcT+6F0RcU4qYpVBiyIOs9N4SYShb2rD5Yn+3hZE4kSbQl8ibTtr9GmHHSmUd5jYF2LfqUm8TuwH2YRP8sOs1xznI6yzyZakma4U1PD+tPlj1wdvqcix9UxxWMsxH5mrdUl2khjK61luxuTK5xOL+fpbntHTVp8VauD8WM8YtlwdeJDpuUou64l7o7TmEVLr+/JSmjm0zz9Naih67+Z+lxaNl/d38Abl5vJj+9hb9JuTyXLX5zg5QmVH82/wsuNPQoe/fu2P5KHhOsZo7+7XL/2hLA7bo+zo0Wt8/4v/fEx8O/7eG41GvafTv9cjLG5u8L+aAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8H/AvKWiXJeQPn34AAAAASUVORK5CYII="
}

58
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"docx": "^9.5.1",
"next": "15.5.4",
"pptxgenjs": "^3.12.0",
"react": "19.1.1",
"react-dom": "19.1.1"
},
@@ -1189,6 +1190,27 @@
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -1704,12 +1726,48 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pptxgenjs": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
"integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.7.3",
"https": "^1.0.0",
"image-size": "^1.0.0",
"jszip": "^3.7.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",

View File

@@ -9,17 +9,18 @@
"lint": "next lint"
},
"dependencies": {
"docx": "^9.5.1",
"next": "15.5.4",
"pptxgenjs": "^3.12.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"docx": "^9.5.1"
"react-dom": "19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9"
"@types/react-dom": "^19.1.9",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2"
}
}

View File

@@ -3,7 +3,7 @@
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf .test-build
npx --yes tsc src/lib/dossier.ts src/lib/kb.ts src/lib/scoring.ts src/lib/client.ts \
npx --yes tsc src/lib/dossier.ts src/lib/kb.ts src/lib/scoring.ts src/lib/client.ts src/lib/finance.ts src/lib/timeline.ts src/lib/shelfware.ts src/lib/portfolio.ts \
--outDir .test-build --target ES2022 --module esnext \
--moduleResolution bundler --skipLibCheck
# tsc emits extensionless relative imports; Node ESM needs the .js suffix.

133
scripts/test-finance.mjs Normal file
View File

@@ -0,0 +1,133 @@
/**
* Financial engine, planner, scenarios, shelfware and knowledge-base curation.
* Pure logic against the compiled libs — no server needed.
*/
import { readFileSync } from 'node:fs';
import { rollup, lineCost } from '../.test-build/scoring.js';
import { evaluate, allScenarios, cashFlow } from '../.test-build/finance.js';
import { buildPlan } from '../.test-build/timeline.js';
import { shelfware } from '../.test-build/shelfware.js';
import { findDuplicates, mergeEntries, learnFromAssessment, newEntry, confidenceOf } from '../.test-build/kb.js';
let pass = 0, fail = 0;
const t = (n, ok, x = '') => { ok ? (pass++, console.log(' PASS', n)) : (fail++, console.log(' FAIL', n, x)); };
const near = (a, b, tol = 1) => Math.abs(a - b) <= tol;
const catalog = JSON.parse(readFileSync(new URL('../data/catalog.json', import.meta.url)));
const TODAY = new Date('2026-09-24T12:00:00Z'); // plan month 0 = Oct 2026
const line = (id, over = {}) => ({
id, incumbentVendor: `Vendor${id}`, incumbentProduct: 'Tool', domain: 'observability',
productIds: ['aod-dx-oi'], annualSpend: 1_000_000, units: 0, unitLabel: '', renewalDate: '',
capability: 4, switchEase: 4, timeToRoiMonths: 6, savingsPct: 50, migrationCost: 100_000,
strategicValue: 3, risks: '', notes: '', included: true, ...over,
});
const base = (candidates, extra = {}) => ({
id: 't', version: 1, customerName: 'T', industry: '', preparedBy: '', preparedFor: '', opportunityRef: '',
executiveSummary: '', horizonYears: 3, weights: { capability: 35, switchEase: 25, timeToRoi: 25, spend: 15 },
candidates, createdAt: '', updatedAt: '', ...extra,
});
console.log('TCO');
{
const c = lineCost(line('a'), 150_000);
t('default TCO = licence + 15% infra + 0.25 FTE + 5% other', near(c.tco, 1_000_000 + 150_000 + 37_500 + 50_000), c.tco);
t('unknown spend carries no phantom admin', lineCost(line('b', { annualSpend: 0 }), 150_000).tco === 0);
const o = lineCost(line('c', { tco: { infra: 10, adminFte: 2, other: null } }), 100_000);
t('overrides win per category', o.infra === 10 && o.admin === 200_000 && near(o.other, 50_000));
}
console.log('Savings model');
{
const r = rollup(base([line('a')]), catalog);
const m = r.metrics.get('a');
t('no investment: net = savings % of TCO', near(m.annualSavings, m.tco * 0.5) && !r.hasInvestment);
const inv = base([line('a'), line('b', { annualSpend: 3_000_000 }), line('c', { investment: { annual: 50_000, services: null } }), line('d', { alreadyEntitled: 'X' })],
{ finance: { discountRate: 10, fteCost: 150_000, investment: { annual: 1_000_000, services: 200_000 } } });
const ri = rollup(inv, catalog);
const [ma, mb, mc, md] = ['a', 'b', 'c', 'd'].map((k) => ri.metrics.get(k));
t('with investment: gross = full TCO', near(ma.grossRemoved, ma.tco));
t('per-line price wins', mc.subscription === 50_000);
t('entitled line takes no subscription share', md.subscription === 0);
t('pool shared by TCO', near(ma.subscription / mb.subscription, ma.tco / mb.tco, 0.001));
t('subscriptions sum to the deal figure', near(ri.totalSubscription, 1_000_000, 2), ri.totalSubscription);
t('services pool fully attributed', near(ri.totalMigrationCost, 4 * 100_000 + 200_000, 2), ri.totalMigrationCost);
}
console.log('Cash flow');
{
const a = base([line('a'), line('b', { renewalDate: '2027-06-30', switchEase: 3 })],
{ finance: { discountRate: 10, fteCost: 150_000, investment: { annual: 400_000, services: 0 } } });
const e = evaluate(a, catalog, undefined, TODAY);
const sumYears = e.cash.years.reduce((s, y) => s + y.net, 0);
t('years add up to the horizon net', near(sumYears, e.cash.horizonNet, 2), `${sumYears} vs ${e.cash.horizonNet}`);
t('subscription billed from month 0', e.cash.months[0] < 0);
t('NPV below undiscounted net', e.cash.npv < e.cash.horizonNet);
t('payback found', e.cash.paybackMonth !== null && e.cash.paybackMonth > 0, e.cash.paybackMonth);
t('IRR solved', e.cash.irr !== null && e.cash.irr > 0, e.cash.irr);
const b = e.plan.items.find((i) => i.id === 'b');
t('contract line cuts over by contract end (Jun 2027 = month 8)', b.end <= 9 && b.savingsFrom === 9, JSON.stringify(b));
}
console.log('Planner overrides');
{
const a = base([
line('p', { plan: { start: '2027-04', duration: 4 } }),
line('q', { plan: { after: ['p'] } }),
line('x', { plan: { after: ['y'] } }), line('y', { plan: { after: ['x'] } }),
]);
const r = rollup(a, catalog);
const plan = buildPlan(a, r, catalog, TODAY);
const get = (id) => plan.items.find((i) => i.id === id);
t('pinned start and duration honoured', get('p').start === 6 && get('p').end === 10 && get('p').anchor === 'pinned');
t('dependency waits for predecessor', get('q').start >= get('p').end, `${get('q').start}`);
t('dependency cycle still schedules', !!get('x') && !!get('y'));
const pinnedEarly = base([line('p'), line('q', { plan: { start: '2026-10', after: ['p'] } })]);
const pe = buildPlan(pinnedEarly, rollup(pinnedEarly, catalog), catalog, TODAY);
t('pinned before its predecessor is flagged', pe.items.find((i) => i.id === 'q').conflict && pe.conflictCount === 1);
}
console.log('Scenarios');
{
const a = base([line('a'), line('b'), line('c')], { scenarios: [{ id: 's1', name: 'Only A', kind: 'custom', valueFactor: 1, excluded: ['b', 'c'] }] });
const net = Object.fromEntries(allScenarios(a).map((s) => [s.id, evaluate(a, catalog, s, TODAY).cash.runRateNet]));
t('conservative < expected < aggressive', net.conservative < net.expected && net.expected < net.aggressive, JSON.stringify(net));
t('custom scope excludes lines', near(net.s1 * 3, net.expected, 5), `${net.s1} vs ${net.expected}`);
t('scenario does not mutate the assessment', a.candidates.every((c) => c.included));
}
console.log('Shelfware');
{
const a = base([line('lm', { productIds: ['aod-dx-uim'] })], { accountIntel: { consumption: [
{ product: 'UIM', entitled: 100, deployed: 40, adoption: '' },
{ product: 'VCF OPERATIONS', entitled: 10, deployed: 0, adoption: '' },
] } });
const s = shelfware(a, catalog);
t('acronym prefers the product in the plan', s[0].match?.id === 'aod-dx-uim' && s[0].linked[0] === 'lm', s[0].match?.name);
t('exact name is decisive', s[1].match?.name === 'VCF Operations', s[1].match?.name);
}
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'; 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');
const ltm = merged.entries.find((e) => e.id === 'f5-big-ip-ltm');
t('merge moves deal history and adds alias', ltm.observations.length === 1 && ltm.aliases.includes('F5 BIG-IP'));
t('merged entry is removed', !merged.entries.some((e) => e.id === 'f5-big-ip'));
const relearn = learnFromAssessment(merged, { ...a, updatedAt: 'x' }) ?? merged;
t('learning after merge lands on the survivor', !relearn.entries.some((e) => e.id === 'f5-big-ip')
&& relearn.entries.find((e) => e.id === 'f5-big-ip-ltm').observations.length === 1);
t('one deal = low confidence', confidenceOf(ltm).level === 'low');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);

View File

@@ -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('<p:sld') && deck.length > 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' });
}

View File

@@ -18,6 +18,10 @@ echo "=== dossier parser ==="
./scripts/build-test-lib.sh
node scripts/test-dossier.mjs || fail=1
echo
echo "=== financial engine, planner, scenarios, curation ==="
node scripts/test-finance.mjs || fail=1
echo
echo "=== knowledge base: learning ==="
reseed

View 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"`,
},
});
}

View File

@@ -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",

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { createSnapshot, getAssessment, getSnapshot, saveAssessment } from "@/lib/store";
type Ctx = { params: Promise<{ id: string; sid: string }> };
export async function GET(_req: Request, { params }: Ctx) {
const { id, sid } = await params;
const s = await getSnapshot(id, sid);
return s ? NextResponse.json(s) : NextResponse.json({ error: "Not found" }, { status: 404 });
}
/**
* Restore: the current state is snapshotted first, so a restore can itself be
* undone. The assessment keeps its id; only its content rolls back.
*/
export async function POST(req: Request, { params }: Ctx) {
const { id, sid } = await params;
const body = (await req.json().catch(() => ({}))) as { action?: string };
if (body.action !== "restore") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
const [current, snap] = await Promise.all([getAssessment(id), getSnapshot(id, sid)]);
if (!current || !snap) return NextResponse.json({ error: "Not found" }, { status: 404 });
await createSnapshot(current, `Before restoring “${snap.label}”`, "restore");
const restored = await saveAssessment({ ...snap.assessment, id, createdAt: current.createdAt });
return NextResponse.json(restored);
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { createSnapshot, getAssessment, listSnapshots } from "@/lib/store";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_req: Request, { params }: Ctx) {
const { id } = await params;
if (!(await getAssessment(id))) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(await listSnapshots(id));
}
/** Body: { label, trigger?: "manual" | "print" } — exports snapshot themselves server-side. */
export async function POST(req: Request, { params }: Ctx) {
const { id } = await params;
const a = await getAssessment(id);
if (!a) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = (await req.json().catch(() => ({}))) as { label?: string; trigger?: string };
const trigger = body.trigger === "print" ? "print" : "manual";
return NextResponse.json(await createSnapshot(a, body.label ?? "", trigger));
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getAssessment, getCatalog, listAssessments } from "@/lib/store";
import { buildPortfolio } from "@/lib/portfolio";
import type { Assessment } from "@/lib/types";
export async function GET() {
const [summaries, catalog] = await Promise.all([listAssessments(), getCatalog()]);
const all = (await Promise.all(summaries.map((s) => getAssessment(s.id)))).filter(Boolean) as Assessment[];
return NextResponse.json(buildPortfolio(all, catalog));
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getBranding, saveBranding } from "@/lib/store";
import { isLogoDataUrl } from "@/lib/images";
export async function GET() {
return NextResponse.json(await getBranding());
}
export async function PUT(req: Request) {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return NextResponse.json({ error: "Malformed request" }, { status: 400 });
const accent = typeof body.accent === "string" && /^#[0-9a-fA-F]{6}$/.test(body.accent) ? body.accent : null;
if (!accent) return NextResponse.json({ error: "Accent must be a hex colour like #005C8A." }, { status: 400 });
if (body.logo !== "" && !isLogoDataUrl(body.logo)) {
return NextResponse.json({ error: "Logo must be a PNG or JPEG under 600 KB." }, { status: 400 });
}
return NextResponse.json(await saveBranding({
companyName: String(body.companyName ?? "").trim().slice(0, 80),
accent,
logo: (body.logo as string) || "",
}));
}

View File

@@ -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">

View File

@@ -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&rsquo;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>
</>

View File

@@ -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>
);
}

View File

@@ -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); }

View File

@@ -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

View File

@@ -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&rsquo;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 &amp; 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 &amp; 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>
);
}

View File

@@ -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
View 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>
);
}

View File

@@ -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
View 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>
);
}

View File

@@ -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),
])}
/>

View File

@@ -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
View 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>
);
}

View File

@@ -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})`} />

View 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>
);
}

View File

@@ -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() {

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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;
}

153
src/lib/finance.ts Normal file
View File

@@ -0,0 +1,153 @@
import type { Assessment, Catalog, Scenario } from "./types";
import { type Rollup, financeOf, rollup } from "./scoring";
import { type Plan, DEFAULT_PARALLEL, buildPlan } from "./timeline";
/**
* Ramp-aware cash flow: month by month over the planning horizon, driven by
* the implementation plan.
*
* - Cost removed arrives per line from its savings date (cutover, or contract end).
* - Our subscription is billed in full from month 0 — how enterprise agreements
* are actually billed, and the conservative choice while savings ramp in.
* - Migration and a line's services land when its project starts; deal-level
* services not attributed to a planned line land in month 0.
* - Unmapped lines have no replacement and so contribute nothing.
*/
export type YearFlow = { year: number; removed: number; subscription: number; oneTime: number; net: number; cumulative: number };
export type CashFlow = {
months: number[];
years: YearFlow[];
/** Net over the horizon, ramp included. The headline figure. */
horizonNet: number;
npv: number;
/** Annualised IRR over the horizon, %; null when the flows never change sign. */
irr: number | null;
/** Month the cumulative position turns positive; 0 if never negative, null if never recovered. */
paybackMonth: number | null;
/** Net annual benefit once every planned line is live. */
runRateNet: number;
oneTimeTotal: number;
};
export function cashFlow(a: Assessment, r: Rollup, plan: Plan): CashFlow {
const f = financeOf(a);
const horizon = a.horizonYears * 12;
const months = new Array<number>(horizon).fill(0);
const monthlySub = r.hasInvestment ? r.totalSubscription / 12 : 0;
let attributedServices = 0;
for (const it of plan.items) {
const m = r.metrics.get(it.id)!;
const annual = r.hasInvestment ? it.grossRemoved : it.annualSavings;
for (let t = it.savingsFrom; t < horizon; t++) months[t] += annual / 12;
if (it.start < horizon) months[it.start] -= it.migrationCost;
attributedServices += m.services;
}
const serviceRemainder = r.hasInvestment ? Math.max(0, f.investment.services - attributedServices) : 0;
if (horizon > 0) months[0] -= serviceRemainder;
for (let t = 0; t < horizon; t++) months[t] -= monthlySub;
const years: YearFlow[] = [];
let cumulative = 0;
for (let y = 0; y < a.horizonYears; y++) {
let removed = 0, oneTime = 0;
for (const it of plan.items) {
const annual = r.hasInvestment ? it.grossRemoved : it.annualSavings;
const live = Math.max(0, Math.min(12, (y + 1) * 12 - Math.max(it.savingsFrom, y * 12)));
removed += (annual / 12) * live;
if (it.start >= y * 12 && it.start < (y + 1) * 12) oneTime += it.migrationCost;
}
if (y === 0) oneTime += serviceRemainder;
const subscription = monthlySub * 12;
const net = removed - subscription - oneTime;
cumulative += net;
years.push({ year: y + 1, removed, subscription, oneTime, net, cumulative });
}
const monthlyRate = Math.pow(1 + f.discountRate / 100, 1 / 12) - 1;
const npvAt = (rate: number) => months.reduce((s, v, t) => s + v / Math.pow(1 + rate, t + 1), 0);
const npv = npvAt(monthlyRate);
let paybackMonth: number | null = 0;
let run = 0, wentNegative = false;
for (let t = 0; t < horizon; t++) {
run += months[t];
if (run < 0) { wentNegative = true; paybackMonth = null; }
else if (wentNegative && paybackMonth === null) paybackMonth = t + 1;
}
if (wentNegative && run < 0) paybackMonth = null;
const planned = plan.items.reduce((s, it) => s + (r.hasInvestment ? it.grossRemoved : it.annualSavings), 0);
const oneTimeTotal = plan.items.reduce((s, it) => s + it.migrationCost, 0) + serviceRemainder;
return {
months, years,
horizonNet: months.reduce((s, v) => s + v, 0),
npv,
irr: irr(months),
paybackMonth,
runRateNet: planned - monthlySub * 12,
oneTimeTotal,
};
}
/** Monthly IRR by bisection, annualised. Null when there is no sign change to solve for. */
function irr(flows: number[]): number | null {
if (!flows.some((v) => v < 0) || !flows.some((v) => v > 0)) return null;
const npv = (rate: number) => flows.reduce((s, v, t) => s + v / Math.pow(1 + rate, t + 1), 0);
let lo = -0.99, hi = 1;
if (npv(lo) * npv(hi) > 0) return null;
for (let i = 0; i < 200; i++) {
const mid = (lo + hi) / 2;
if (npv(lo) * npv(mid) <= 0) hi = mid; else lo = mid;
}
return (Math.pow(1 + (lo + hi) / 2, 12) - 1) * 100;
}
/* ------------------------------------------------------------ scenarios */
export const EXPECTED_ID = "expected";
/** Conservative / Expected / Aggressive, derived from the assessment as it stands. */
export function presetScenarios(a: Assessment): Scenario[] {
const p = a.planParallel ?? DEFAULT_PARALLEL;
return [
{ id: "conservative", name: "Conservative", kind: "preset", valueFactor: 0.75, parallel: Math.max(1, p - 1) },
{ id: EXPECTED_ID, name: "Expected", kind: "preset", valueFactor: 1 },
{ id: "aggressive", name: "Aggressive", kind: "preset", valueFactor: 1.15, parallel: Math.min(4, p + 1) },
];
}
export function allScenarios(a: Assessment): Scenario[] {
return [...presetScenarios(a), ...(a.scenarios ?? [])];
}
export function findScenario(a: Assessment, id: string | undefined): Scenario {
const all = allScenarios(a);
return all.find((s) => s.id === (id ?? EXPECTED_ID)) ?? all.find((s) => s.id === EXPECTED_ID)!;
}
/** The assessment as a scenario sees it. */
export function applyScenario(a: Assessment, s: Scenario): Assessment {
const next = structuredClone(a);
if (s.excluded?.length) {
const out = new Set(s.excluded);
for (const c of next.candidates) if (out.has(c.id)) c.included = false;
}
if (s.parallel) next.planParallel = s.parallel;
if (s.investment) next.finance = { ...financeOf(next), investment: { ...s.investment } };
return next;
}
export type Evaluation = { scenario: Scenario; a: Assessment; r: Rollup; plan: Plan; cash: CashFlow };
/** Everything a view needs for one scenario: rollup, plan and cash flow. */
export function evaluate(a: Assessment, catalog: Catalog, s?: Scenario, today = new Date()): Evaluation {
const scenario = s ?? findScenario(a, EXPECTED_ID);
const sa = applyScenario(a, scenario);
const r = rollup(sa, catalog, scenario.valueFactor);
const plan = buildPlan(sa, r, catalog, today);
return { scenario, a: sa, r, plan, cash: cashFlow(sa, r, plan) };
}

27
src/lib/images.ts Normal file
View File

@@ -0,0 +1,27 @@
/** Logos travel as data URLs inside JSON, so they are checked for type and size at every boundary. */
export const MAX_LOGO_BYTES = 600_000;
export function isLogoDataUrl(v: unknown): v is string {
if (typeof v !== "string") return false;
const m = /^data:image\/(png|jpeg);base64,([A-Za-z0-9+/=]+)$/.exec(v);
return !!m && (m[2].length * 3) / 4 <= MAX_LOGO_BYTES;
}
/** PNG / JPEG pixel size from the file header, so exports can keep the aspect ratio. */
export function imageSize(dataUrl: string): { w: number; h: number; type: "png" | "jpg" } | null {
const m = /^data:image\/(png|jpeg);base64,(.+)$/.exec(dataUrl);
if (!m) return null;
const b = Buffer.from(m[2], "base64");
if (m[1] === "png" && b.length > 24) return { w: b.readUInt32BE(16), h: b.readUInt32BE(20), type: "png" };
// JPEG: walk the segments to the first start-of-frame marker.
let i = 2;
while (i + 9 < b.length) {
if (b[i] !== 0xff) { i++; continue; }
const marker = b[i + 1];
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
return { h: b.readUInt16BE(i + 5), w: b.readUInt16BE(i + 7), type: "jpg" };
}
i += 2 + b.readUInt16BE(i + 2);
}
return null;
}

View File

@@ -141,13 +141,16 @@ export function learnFromAssessment(kb: KnowledgeBase, a: Assessment): Knowledge
const before = JSON.stringify(kb.entries);
const byId = new Map(kb.entries.map((e) => [e.id, e]));
const now = new Date().toISOString();
// A merged-away name lives on as an alias; learning must land on the survivor,
// or the next save would re-create the duplicate the curator just removed.
const byName = nameIndex(kb.entries);
for (const c of a.candidates) {
const label = candidateLabel(c);
// Only capture entries a seller has actually filled in and mapped.
if (!label || c.productIds.length === 0) continue;
const id = kbId(label);
const id = byName.get(nameKey(label)) ?? kbId(label);
let entry = byId.get(id);
if (!entry) {
entry = newEntry(label, "learned");
@@ -241,3 +244,137 @@ export function searchKb(kb: KnowledgeBase, query: string, limit = 8): KbEntry[]
.slice(0, limit)
.map((x) => x.e);
}
/* ------------------------------------------------------ names and aliases */
const nameKey = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
/** Label and every alias → entry id. */
function nameIndex(entries: KbEntry[]) {
const m = new Map<string, string>();
for (const e of entries) {
for (const n of [e.label, ...e.aliases]) if (!m.has(nameKey(n))) m.set(nameKey(n), e.id);
}
return m;
}
/** The entry a typed or picked tool name refers to, by label or alias. */
export function findEntry(kb: KnowledgeBase | null, label: string): KbEntry | null {
if (!kb || !label.trim()) return null;
const k = nameKey(label);
return kb.entries.find((e) => nameKey(e.label) === k || e.aliases.some((x) => nameKey(x) === k)) ?? null;
}
/* ------------------------------------------------------ confidence */
export type Confidence = { level: "high" | "medium" | "low"; source: string; stale: boolean; overridden: boolean };
const STALE_MS = 365 * 24 * 3600 * 1000;
/**
* How far to trust an entry's defaults: 5+ deals is high, 2–4 or curated is
* medium, one deal or catalog-seeded is low. A curator can override it; an
* entry not reviewed in 12 months is flagged stale.
*/
export function confidenceOf(e: KbEntry, now = Date.now()): Confidence {
const deals = new Set(e.observations.map((o) => o.assessmentId)).size;
const source = deals > 0
? `Median of ${deals} deal${deals === 1 ? "" : "s"}${e.pinned ? ", curated" : ""}`
: e.pinned ? `Curated${e.owner ? ` by ${e.owner}` : ""}` : "Seeded from the product catalog";
const computed: Confidence["level"] = deals >= 5 ? "high" : deals >= 2 || e.pinned ? "medium" : "low";
const reviewed = e.reviewedAt ? Date.parse(e.reviewedAt) : NaN;
return {
level: e.confidenceOverride ?? computed,
source,
stale: (e.pinned || !!e.reviewedAt) && (isNaN(reviewed) || now - reviewed > STALE_MS),
overridden: !!e.confidenceOverride,
};
}
/* ------------------------------------------------------ duplicates and merge */
const GENERIC = new Set(["inc", "software", "platform", "suite", "enterprise", "security", "cloud", "the"]);
const tokens = (s: string) => nameKey(s.replace(/\(.*?\)/g, " ")).split(" ").filter((t) => t && !GENERIC.has(t));
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("|");
/**
* Entries that look like the same tool: identical once generic words are
* dropped, one name contained in the other (same vendor), or a shared alias.
* Nothing is merged automatically — a curator decides.
*/
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), 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];
if (distinct.has(pairKey(x.e.id, y.e.id))) continue;
if (x.e.domain && y.e.domain && x.e.domain !== y.e.domain) continue;
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.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 });
}
}
// Pairs with deal history first — those are the ones splitting learned data.
return out.sort((p, q) => (q.a.observations.length + q.b.observations.length) - (p.a.observations.length + p.b.observations.length));
}
/**
* Folds `dropId` into `keepId`: its name and aliases become aliases, its deal
* history moves over, and gaps in the survivor are filled from it.
*/
export function mergeEntries(kb: KnowledgeBase, keepId: string, dropId: string): KnowledgeBase {
const keep = kb.entries.find((e) => e.id === keepId);
const drop = kb.entries.find((e) => e.id === dropId);
if (!keep || !drop || keep === drop) return kb;
const seen = new Set([nameKey(keep.label)]);
const aliases: string[] = [];
for (const n of [...keep.aliases, drop.label, ...drop.aliases]) {
if (seen.has(nameKey(n))) continue;
seen.add(nameKey(n));
aliases.push(n);
}
const obs = new Map(keep.observations.map((o) => [o.sourceKey, o]));
for (const o of drop.observations) {
const cur = obs.get(o.sourceKey);
if (!cur || cur.recordedAt < o.recordedAt) obs.set(o.sourceKey, o);
}
const merged = recomputeDefaults({
...keep,
aliases,
observations: [...obs.values()],
domain: keep.domain || drop.domain,
productIds: keep.productIds.length ? keep.productIds : drop.productIds,
unitLabel: keep.unitLabel || drop.unitLabel,
risks: keep.risks || drop.risks,
notes: [keep.notes, drop.notes].filter(Boolean).join("\n\n"),
tco: keep.tco ?? drop.tco,
updatedAt: new Date().toISOString(),
});
return {
...kb,
entries: kb.entries.filter((e) => e.id !== dropId).map((e) => (e.id === keepId ? merged : e)),
distinctPairs: (kb.distinctPairs ?? []).filter((p) => !p.split("|").includes(dropId)),
updatedAt: new Date().toISOString(),
};
}

77
src/lib/portfolio.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { Assessment, Catalog, DealStatus } from "./types";
import { evaluate, findScenario } from "./finance";
import { candidateLabel } from "./kb";
export type PortfolioRow = {
id: string; customerName: string; status: DealStatus; scenario: string;
netAnnual: number; horizonNet: number; npv: number; horizonYears: number;
tco: number; toolsBefore: number; toolsAfter: number; vendorsBefore: number; updatedAt: string;
};
export type Portfolio = {
rows: PortfolioRow[];
/** Everything except lost deals. */
totals: { accounts: number; netAnnual: number; horizonNet: number; tco: number; tools: number; vendors: number };
byStatus: { status: DealStatus; accounts: number; netAnnual: number }[];
byBu: { buId: string; name: string; color: string; savings: number; accounts: number }[];
competitors: { label: string; accounts: number; lines: number; tco: number }[];
};
export const STATUS_LABEL: Record<DealStatus, string> = { open: "Open", won: "Won", lost: "Lost", paused: "Paused" };
/** Each assessment is valued as it is presented to the customer (its presented scenario). */
export function buildPortfolio(list: Assessment[], catalog: Catalog, today = new Date()): Portfolio {
const rows: PortfolioRow[] = [];
const bu = new Map<string, { buId: string; name: string; color: string; savings: number; accounts: Set<string> }>();
const comp = new Map<string, { label: string; accounts: Set<string>; lines: number; tco: number }>();
const status = new Map<DealStatus, { accounts: number; netAnnual: number }>();
const totals = { accounts: 0, netAnnual: 0, horizonNet: 0, tco: 0, tools: 0, vendors: 0 };
for (const a of list) {
const e = evaluate(a, catalog, findScenario(a, a.presentedScenario), today);
const st: DealStatus = a.status ?? "open";
rows.push({
id: a.id, customerName: a.customerName, status: st, scenario: e.scenario.name,
netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, npv: e.cash.npv, horizonYears: a.horizonYears,
tco: e.r.addressableTco, toolsBefore: e.r.toolsRetired, toolsAfter: e.r.toolsAfter,
vendorsBefore: e.r.vendorsRetired, updatedAt: a.updatedAt,
});
const s = status.get(st) ?? { accounts: 0, netAnnual: 0 };
s.accounts++; s.netAnnual += e.cash.runRateNet;
status.set(st, s);
if (st === "lost") continue;
totals.accounts++;
totals.netAnnual += e.cash.runRateNet;
totals.horizonNet += e.cash.horizonNet;
totals.tco += e.r.addressableTco;
totals.tools += e.r.toolsRetired;
totals.vendors += e.r.vendorsRetired;
for (const b of e.r.byBu) {
const x = bu.get(b.buId) ?? { buId: b.buId, name: b.name, color: b.color, savings: 0, accounts: new Set<string>() };
x.savings += b.savings; x.accounts.add(a.id);
bu.set(b.buId, x);
}
for (const c of e.r.included) {
const label = candidateLabel(c);
if (!label) continue;
const k = label.toLowerCase();
const x = comp.get(k) ?? { label, accounts: new Set<string>(), lines: 0, tco: 0 };
x.accounts.add(a.id); x.lines++; x.tco += e.r.metrics.get(c.id)!.tco;
comp.set(k, x);
}
}
return {
rows: rows.sort((x, y) => y.netAnnual - x.netAnnual),
totals,
byStatus: (["open", "won", "paused", "lost"] as DealStatus[]).map((st) => ({ status: st, ...(status.get(st) ?? { accounts: 0, netAnnual: 0 }) })),
byBu: [...bu.values()].map((x) => ({ ...x, accounts: x.accounts.size })).sort((x, y) => y.savings - x.savings),
competitors: [...comp.values()]
.map((x) => ({ label: x.label, accounts: x.accounts.size, lines: x.lines, tco: x.tco }))
.sort((x, y) => y.accounts - x.accounts || y.tco - x.tco)
.slice(0, 15),
};
}

View File

@@ -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 } } : {}),
})),
};
}

View File

@@ -1,4 +1,4 @@
import type { Assessment, Candidate, Catalog, Weights } from "./types";
import type { Assessment, Candidate, Catalog, Finance, TcoDefaults, Weights } from "./types";
export const DEFAULT_WEIGHTS: Weights = {
capability: 35,
@@ -7,6 +7,28 @@ export const DEFAULT_WEIGHTS: Weights = {
spend: 15,
};
/** TCO baseline for a line with no knowledge-base entry behind it. */
export const TCO_FALLBACK: TcoDefaults = { infraPct: 15, adminFte: 0.25, otherPct: 5 };
export const FINANCE_DEFAULTS: Finance = {
discountRate: 10,
fteCost: 150_000,
investment: { annual: 0, services: 0 },
};
/** Fills gaps so older assessments without a finance block still compute. */
export function financeOf(a: Pick<Assessment, "finance">): Finance {
const f = a.finance;
return {
discountRate: f?.discountRate ?? FINANCE_DEFAULTS.discountRate,
fteCost: f?.fteCost ?? FINANCE_DEFAULTS.fteCost,
investment: {
annual: Math.max(0, f?.investment?.annual ?? 0),
services: Math.max(0, f?.investment?.services ?? 0),
},
};
}
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
/** ROI speed as a 0–1 score: <=6 months is ideal, >=36 months scores zero. */
@@ -14,7 +36,48 @@ export function roiNorm(months: number) {
return clamp01(1 - (months - 6) / 30);
}
export type CandidateMetrics = {
/* ------------------------------------------------------------ line cost */
export type LineCost = {
licence: number;
infra: number;
adminFte: number;
admin: number;
other: number;
/** Full annual cost of the incumbent: licence + infra + admin + other. */
tco: number;
/** Which of infra / admin / other the seller has overridden. */
overridden: { infra: boolean; adminFte: boolean; other: boolean };
};
export function lineCost(c: Candidate, fteCost: number): LineCost {
const d = c.tcoDefaults ?? TCO_FALLBACK;
const licence = Math.max(0, c.annualSpend);
const o = c.tco;
const infra = Math.max(0, o?.infra ?? (licence * d.infraPct) / 100);
// An unknown spend is an unknown tool: no phantom admin cost until spend is captured.
const adminFte = Math.max(0, o?.adminFte ?? (licence > 0 ? d.adminFte : 0));
const admin = adminFte * fteCost;
const other = Math.max(0, o?.other ?? (licence * d.otherPct) / 100);
return {
licence, infra, adminFte, admin, other,
tco: licence + infra + admin + other,
overridden: { infra: o?.infra != null, adminFte: o?.adminFte != null, other: o?.other != null },
};
}
/* ------------------------------------------------------------ metrics */
export type CandidateMetrics = LineCost & {
/** Cost removed from the customer each year once this line is retired. */
grossRemoved: number;
/** Our subscription attributed to this line (0 when no investment is recorded). */
subscription: number;
/** Services attributed to this line. */
services: number;
/** One-time cost: migration plus attributed services. */
oneTime: number;
/** Net annual benefit at run-rate: gross removed less our subscription. */
annualSavings: number;
horizonGross: number;
horizonNet: number;
@@ -24,13 +87,32 @@ export type CandidateMetrics = {
wave: 1 | 2 | 3;
};
export type BuValue = {
buId: string; name: string; short: string; color: string;
savings: number; spend: number; migrationCost: number; horizonNet: number;
/** Incumbent lines this BU absorbs (a line mapped to two BUs counts for both). */
count: number;
productIds: string[];
};
export type Rollup = {
metrics: Map<string, CandidateMetrics>;
included: Candidate[];
totalAnnualSpend: number;
/** Licence spend of included lines. */
addressableSpend: number;
/** Full TCO of included lines. */
addressableTco: number;
/** Gross cost removed per year at run-rate. */
totalGrossRemoved: number;
/** Our annual subscription, deal-level (internal). */
totalSubscription: number;
/** Net annual benefit at run-rate. */
totalAnnualSavings: number;
/** One-time cost: migration plus services. */
totalMigrationCost: number;
/** Whether our investment has been recorded; switches the model from savings % to gross − investment. */
hasInvestment: boolean;
horizonGross: number;
horizonNet: number;
blendedPaybackMonths: number | null;
@@ -45,83 +127,113 @@ export type Rollup = {
unmappedCount: number;
byWave: Record<1 | 2 | 3, Candidate[]>;
byBu: BuValue[];
byDomain: { domainId: string; name: string; savings: number; spend: number; count: number }[];
byDomain: { domainId: string; name: string; savings: number; spend: number; count: number; vendors: number }[];
};
export type BuValue = {
buId: string; name: string; short: string; color: string;
savings: number; spend: number; migrationCost: number; horizonNet: number;
/** Incumbent lines this BU absorbs (a line mapped to two BUs counts for both). */
count: number;
productIds: string[];
};
export function scoreCandidate(
c: Candidate,
weights: Weights,
maxSpend: number,
horizonYears: number,
): CandidateMetrics {
function scoreOf(c: Candidate, weights: Weights, cost: number, maxCost: number) {
const cap = clamp01((c.capability - 1) / 4);
const ease = clamp01((c.switchEase - 1) / 4);
const roi = roiNorm(c.timeToRoiMonths);
// Square-root scaling so a single very large line item does not swamp the rest.
const spendN = maxSpend > 0 ? clamp01(Math.sqrt(Math.max(0, c.annualSpend)) / Math.sqrt(maxSpend)) : 0;
const spendN = maxCost > 0 ? clamp01(Math.sqrt(Math.max(0, cost)) / Math.sqrt(maxCost)) : 0;
const w = weights;
const wSum = w.capability + w.switchEase + w.timeToRoi + w.spend || 1;
const score =
(100 * (w.capability * cap + w.switchEase * ease + w.timeToRoi * roi + w.spend * spendN)) / wSum;
const annualSavings = (c.annualSpend * c.savingsPct) / 100;
const horizonGross = annualSavings * horizonYears;
const horizonNet = horizonGross - c.migrationCost;
const paybackMonths =
annualSavings > 0 ? (c.migrationCost / annualSavings) * 12 : c.migrationCost > 0 ? null : 0;
const roiPct = c.migrationCost > 0 ? (horizonNet / c.migrationCost) * 100 : null;
const wave: 1 | 2 | 3 = score >= 65 ? 1 : score >= 45 ? 2 : 3;
return { annualSavings, horizonGross, horizonNet, paybackMonths, roiPct, score, wave };
return (100 * (w.capability * cap + w.switchEase * ease + w.timeToRoi * roi + w.spend * spendN)) / wSum;
}
export function rollup(a: Assessment, catalog: Catalog): Rollup {
/**
* Per-line economics and the assessment rollup.
*
* Two models, by whether our investment is recorded:
* - Without it, each line's net benefit is its savings % of full TCO (the
* phase-1 model, now applied to TCO rather than licence alone).
* - With it, each line removes its full TCO and carries a share of our
* subscription. A line's own pricing wins; the rest of the deal-level figure
* is spread across mapped lines by TCO. Lines already entitled carry none.
*
* `valueFactor` (from a scenario) scales the cost each line removes.
*/
export function rollup(a: Assessment, catalog: Catalog, valueFactor = 1): Rollup {
const f = financeOf(a);
const included = a.candidates.filter((c) => c.included);
const maxSpend = Math.max(0, ...included.map((c) => c.annualSpend));
const costs = new Map(a.candidates.map((c) => [c.id, lineCost(c, f.fteCost)]));
const maxCost = Math.max(0, ...included.map((c) => costs.get(c.id)!.tco));
const hasInvestment = f.investment.annual > 0 || f.investment.services > 0
|| a.candidates.some((c) => c.investment?.annual != null || c.investment?.services != null);
// Deal-level remainders, shared across included, mapped lines with no price of their own.
const shareable = included.filter((c) => c.productIds.length > 0);
const explicitAnnual = shareable.reduce((s, c) => s + (c.investment?.annual ?? 0), 0);
const explicitServices = shareable.reduce((s, c) => s + (c.investment?.services ?? 0), 0);
const annualPool = Math.max(0, f.investment.annual - explicitAnnual);
const servicesPool = Math.max(0, f.investment.services - explicitServices);
const takesAnnual = shareable.filter((c) => c.investment?.annual == null && !c.alreadyEntitled);
const takesServices = shareable.filter((c) => c.investment?.services == null);
const weightOf = (list: Candidate[]) => {
const total = list.reduce((s, c) => s + costs.get(c.id)!.tco, 0);
return (c: Candidate) => (total > 0 ? costs.get(c.id)!.tco / total : 1 / list.length);
};
const wA = weightOf(takesAnnual), wS = weightOf(takesServices);
const metrics = new Map<string, CandidateMetrics>();
for (const c of a.candidates) {
metrics.set(c.id, scoreCandidate(c, a.weights, maxSpend, a.horizonYears));
const cost = costs.get(c.id)!;
const mapped = c.productIds.length > 0 && c.included;
const subscription = !hasInvestment || !mapped ? 0
: c.investment?.annual ?? (takesAnnual.includes(c) ? annualPool * wA(c) : 0);
const services = !hasInvestment || !mapped ? 0
: c.investment?.services ?? (takesServices.includes(c) ? servicesPool * wS(c) : 0);
const grossRemoved = hasInvestment
? cost.tco * valueFactor
: (cost.tco * c.savingsPct * valueFactor) / 100;
const annualSavings = grossRemoved - subscription;
const oneTime = c.migrationCost + services;
const horizonGross = annualSavings * a.horizonYears;
const horizonNet = horizonGross - oneTime;
const paybackMonths = annualSavings > 0 ? (oneTime / annualSavings) * 12 : oneTime > 0 ? null : 0;
const roiPct = oneTime > 0 ? (horizonNet / oneTime) * 100 : null;
const score = scoreOf(c, a.weights, cost.tco, maxCost);
const wave: 1 | 2 | 3 = score >= 65 ? 1 : score >= 45 ? 2 : 3;
metrics.set(c.id, {
...cost, grossRemoved, subscription, services, oneTime, annualSavings,
horizonGross, horizonNet, paybackMonths, roiPct, score, wave,
});
}
const sum = (fn: (m: CandidateMetrics, c: Candidate) => number) =>
included.reduce((s, c) => s + fn(metrics.get(c.id)!, c), 0);
const totalAnnualSpend = a.candidates.reduce((s, c) => s + c.annualSpend, 0);
const addressableSpend = included.reduce((s, c) => s + c.annualSpend, 0);
const totalAnnualSavings = included.reduce((s, c) => s + metrics.get(c.id)!.annualSavings, 0);
const totalMigrationCost = included.reduce((s, c) => s + c.migrationCost, 0);
const addressableSpend = sum((_m, c) => c.annualSpend);
const addressableTco = sum((m) => m.tco);
const totalGrossRemoved = sum((m) => m.grossRemoved);
const lineSubscriptions = sum((m) => m.subscription);
// An entitled-only deal can leave part of the pool unattributed; it is still owed.
const totalSubscription = hasInvestment ? Math.max(lineSubscriptions, f.investment.annual) : 0;
const totalAnnualSavings = totalGrossRemoved - totalSubscription;
const totalServices = hasInvestment ? Math.max(sum((m) => m.services), f.investment.services) : 0;
const totalMigrationCost = sum((_m, c) => c.migrationCost) + totalServices;
const horizonGross = totalAnnualSavings * a.horizonYears;
const horizonNet = horizonGross - totalMigrationCost;
const blendedPaybackMonths =
totalAnnualSavings > 0 ? (totalMigrationCost / totalAnnualSavings) * 12 : null;
const avgScore = included.length
? included.reduce((s, c) => s + metrics.get(c.id)!.score, 0) / included.length
: 0;
const blendedPaybackMonths = totalAnnualSavings > 0 ? (totalMigrationCost / totalAnnualSavings) * 12 : null;
const avgScore = included.length ? sum((m) => m.score) / included.length : 0;
const byWave: Record<1 | 2 | 3, Candidate[]> = { 1: [], 2: [], 3: [] };
for (const c of included) byWave[metrics.get(c.id)!.wave].push(c);
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
// Savings are split evenly across the mapped products so BU credit sums to the total.
// Value is split evenly across the mapped BUs so BU credit sums to the total.
const buAgg = new Map<string, { savings: number; spend: number; migrationCost: number; count: number; products: Set<string> }>();
for (const c of included) {
const m = metrics.get(c.id)!;
const bus = [...new Set(c.productIds.map((id) => prodById.get(id)?.bu).filter(Boolean))] as string[];
if (!bus.length) continue;
for (const bu of bus) {
const e = buAgg.get(bu) ?? { savings: 0, spend: 0, migrationCost: 0, count: 0, products: new Set<string>() };
e.savings += m.annualSavings / bus.length;
e.spend += c.annualSpend / bus.length;
e.migrationCost += c.migrationCost / bus.length;
e.spend += m.tco / bus.length;
e.migrationCost += m.oneTime / bus.length;
e.count += 1;
for (const id of c.productIds) if (prodById.get(id)?.bu === bu) e.products.add(id);
buAgg.set(bu, e);
@@ -140,42 +252,48 @@ export function rollup(a: Assessment, catalog: Catalog): Rollup {
})
.sort((x, y) => y.savings - x.savings);
const mapped = included.filter((c) => c.productIds.length);
const unmapped = included.filter((c) => !c.productIds.length);
const vendorKey = (c: Candidate) => c.incumbentVendor.trim().toLowerCase();
const toolsAfter = new Set(mapped.flatMap((c) => c.productIds)).size + unmapped.length;
const vendorsAfter = (mapped.length ? 1 : 0) + new Set(unmapped.map(vendorKey).filter(Boolean)).size;
const domAgg = new Map<string, { savings: number; spend: number; count: number }>();
const domAgg = new Map<string, { savings: number; spend: number; count: number; vendors: Set<string> }>();
for (const c of included) {
const m = metrics.get(c.id)!;
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 };
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0, vendors: new Set<string>() };
e.savings += m.annualSavings;
e.spend += c.annualSpend;
e.spend += m.tco;
e.count += 1;
if (vendorKey(c)) e.vendors.add(vendorKey(c));
domAgg.set(c.domain, e);
}
const byDomain = catalog.domains
.filter((d) => domAgg.has(d.id))
.map((d) => ({ domainId: d.id, name: d.name, ...domAgg.get(d.id)! }))
.map((d) => {
const e = domAgg.get(d.id)!;
return { domainId: d.id, name: d.name, savings: e.savings, spend: e.spend, count: e.count, vendors: e.vendors.size };
})
.sort((x, y) => y.savings - x.savings);
const mapped = included.filter((c) => c.productIds.length);
const unmapped = included.filter((c) => !c.productIds.length);
return {
metrics,
included,
totalAnnualSpend,
addressableSpend,
addressableTco,
totalGrossRemoved,
totalSubscription,
totalAnnualSavings,
totalMigrationCost,
hasInvestment,
horizonGross,
horizonNet,
blendedPaybackMonths,
savingsPct: addressableSpend > 0 ? (totalAnnualSavings / addressableSpend) * 100 : 0,
savingsPct: addressableTco > 0 ? (totalAnnualSavings / addressableTco) * 100 : 0,
avgScore,
toolsRetired: included.length,
vendorsRetired: new Set(included.map((c) => c.incumbentVendor.trim().toLowerCase()).filter(Boolean)).size,
toolsAfter,
vendorsAfter,
vendorsRetired: new Set(included.map(vendorKey).filter(Boolean)).size,
toolsAfter: new Set(mapped.flatMap((c) => c.productIds)).size + unmapped.length,
vendorsAfter: (mapped.length ? 1 : 0) + new Set(unmapped.map(vendorKey).filter(Boolean)).size,
unmappedCount: unmapped.length,
byWave,
byDomain,

77
src/lib/shelfware.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { Assessment, Catalog, IntelConsumption, Product } from "./types";
/**
* Shelfware: products the customer already owns but has not fully deployed.
*
* The dossier names them by short code ("VSAN", "RALLY", "UIM"), so each is
* matched to a catalog product by exact name, then whole-word containment,
* then acronym ("UIM" → "Unified Infrastructure Management").
*/
const BRAND_WORDS = new Set(["vmware", "broadcom", "dx", "symantec", "ca", "vcf"]);
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
function acronym(name: string) {
return norm(name.replace(/\(.*?\)/g, ""))
.split(" ")
.filter((w) => w && !BRAND_WORDS.has(w) && !["for", "and", "of", "the"].includes(w))
.map((w) => w[0])
.join("");
}
const exactMatches = (code: string, catalog: Catalog) => {
const c = norm(code);
return c ? catalog.products.filter((p) => norm(p.name) === c || norm(p.name.replace(/^vmware /i, "")) === c) : [];
};
/** Every catalog product the code could mean, best match first. */
export function productMatches(code: string, catalog: Catalog): Product[] {
const c = norm(code);
if (!c) return [];
const products = catalog.products;
const exact = exactMatches(code, catalog);
const word = new RegExp(`(^| )${c}( |$)`);
const contains = products.filter((p) => word.test(norm(p.name))).sort((x, y) => x.name.length - y.name.length);
const compact = c.replace(/ /g, "");
const acro = compact.length >= 2 ? products.filter((p) => acronym(p.name) === compact) : [];
return [...new Set([...exact, ...contains, ...acro])];
}
export function matchProduct(code: string, catalog: Catalog): Product | null {
return productMatches(code, catalog)[0] ?? null;
}
export type ShelfItem = IntelConsumption & {
product: string;
match: Product | null;
unused: number;
/** Candidate ids whose replacement uses this entitlement. */
linked: string[];
};
/** Entitled-but-undeployed products and the displacements that could use them. */
export function shelfware(a: Assessment, catalog: Catalog): ShelfItem[] {
const rows = a.accountIntel?.consumption ?? [];
return rows
.filter((x) => x.entitled > x.deployed)
.map((x) => {
// An exact name is decisive. Otherwise a code can fit several products
// ("UIM"), and the one this plan actually uses wins.
const options = productMatches(x.product, catalog);
const using = (p: Product) => a.candidates.filter((c) => c.included && c.productIds.includes(p.id)).map((c) => c.id);
const match = exactMatches(x.product, catalog)[0] ?? options.find((p) => using(p).length) ?? options[0] ?? null;
return { ...x, match, unused: x.entitled - x.deployed, linked: match ? using(match) : [] };
});
}
/**
* The entitlement each candidate can draw on, as the product name to record on
* the line (candidate.alreadyEntitled). Absent = none.
*/
export function entitlementFlags(a: Assessment, catalog: Catalog): Map<string, string> {
const out = new Map<string, string>();
for (const s of shelfware(a, catalog)) {
for (const id of s.linked) if (!out.has(id)) out.set(id, s.match!.name);
}
return out;
}

View File

@@ -1,8 +1,9 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Assessment, AssessmentSummary, Catalog, KnowledgeBase, Survey } from "./types";
import type { Assessment, AssessmentSummary, Branding, Catalog, KnowledgeBase, Snapshot, SnapshotMeta, Survey } from "./types";
import { DEFAULT_WEIGHTS, rollup } from "./scoring";
import { evaluate } from "./finance";
import { forgetAssessment, learnFromAssessment } from "./kb";
import { isSurveyToken } from "./survey";
@@ -11,6 +12,8 @@ const CATALOG_PATH = path.join(DATA_DIR, "catalog.json");
const KB_PATH = path.join(DATA_DIR, "knowledge-base.json");
const ASSESSMENT_DIR = path.join(DATA_DIR, "assessments");
const SURVEY_DIR = path.join(DATA_DIR, "surveys");
const SNAPSHOT_DIR = path.join(DATA_DIR, "snapshots");
const SETTINGS_PATH = path.join(DATA_DIR, "settings.json");
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await fs.readFile(file, "utf8")) as T;
@@ -73,6 +76,7 @@ export async function listAssessments(): Promise<AssessmentSummary[]> {
preparedBy: a.preparedBy,
createdAt: a.createdAt,
updatedAt: a.updatedAt,
status: a.status ?? "open",
candidateCount: a.candidates.length,
annualSpend: r.addressableSpend,
annualSavings: r.totalAnnualSavings,
@@ -114,6 +118,7 @@ export async function deleteAssessment(id: string) {
await fs.rm(assessmentPath(id), { force: true });
for (const sv of await listSurveys(id)) await fs.rm(surveyPath(sv.id), { force: true });
await fs.rm(path.join(SNAPSHOT_DIR, id), { recursive: true, force: true });
try {
const kb = await getKnowledgeBase();
@@ -236,3 +241,71 @@ export async function mutateSurvey(id: string, fn: (s: Survey) => void | string)
export async function deleteSurvey(id: string) {
await fs.rm(surveyPath(id), { force: true });
}
/* ------------------------------------------------------------ branding */
export const DEFAULT_BRANDING: Branding = { companyName: "", accent: "#005C8A", logo: "" };
export async function getBranding(): Promise<Branding> {
try {
return { ...DEFAULT_BRANDING, ...(await readJson<Partial<Branding>>(SETTINGS_PATH)) };
} catch {
return { ...DEFAULT_BRANDING };
}
}
export async function saveBranding(b: Branding): Promise<Branding> {
await writeJson(SETTINGS_PATH, b);
return b;
}
/* ------------------------------------------------------------ snapshots */
const snapshotDir = (assessmentId: string) => {
if (!isSafeId(assessmentId)) throw new Error(`Invalid assessment id: ${assessmentId}`);
return path.join(SNAPSHOT_DIR, assessmentId);
};
export async function listSnapshots(assessmentId: string): Promise<SnapshotMeta[]> {
const dir = snapshotDir(assessmentId);
await fs.mkdir(dir, { recursive: true });
const out: SnapshotMeta[] = [];
for (const f of await fs.readdir(dir)) {
if (!f.endsWith(".json")) continue;
try {
const { assessment: _a, ...meta } = await readJson<Snapshot>(path.join(dir, f));
out.push(meta);
} catch {
// Skip a damaged snapshot rather than hiding the rest.
}
}
return out.sort((x, y) => y.createdAt.localeCompare(x.createdAt));
}
export async function getSnapshot(assessmentId: string, id: string): Promise<Snapshot | null> {
if (!/^[a-z0-9-]{1,40}$/.test(id)) return null;
try {
return await readJson<Snapshot>(path.join(snapshotDir(assessmentId), `${id}.json`));
} catch {
return null;
}
}
/** Freezes the assessment as it stands, with its headline, under a label. */
export async function createSnapshot(a: Assessment, label: string, trigger: SnapshotMeta["trigger"]): Promise<SnapshotMeta> {
const catalog = await getCatalog();
const e = evaluate(a, catalog);
const createdAt = new Date().toISOString();
const snap: Snapshot = {
id: `${createdAt.replace(/[^0-9]/g, "").slice(0, 14)}-${crypto.randomBytes(3).toString("hex")}`,
assessmentId: a.id,
label: label.trim().slice(0, 120) || "Snapshot",
trigger,
createdAt,
headline: { netAnnual: e.cash.runRateNet, horizonNet: e.cash.horizonNet, tools: e.r.toolsRetired },
assessment: structuredClone(a),
};
await writeJson(path.join(snapshotDir(a.id), `${snap.id}.json`), snap);
const { assessment: _a, ...meta } = snap;
return meta;
}

View File

@@ -1,4 +1,4 @@
import type { Assessment, Catalog } from "./types";
import type { Assessment, Candidate, Catalog } from "./types";
import type { Rollup } from "./scoring";
/**
@@ -7,15 +7,16 @@ import type { Rollup } from "./scoring";
* first full month after today), and a project occupies [start, end).
*
* Rules, in order:
* 0. A seller's pinned start and duration always win.
* 1. A tool with a known contract end is scheduled to cut over by that date,
* so the customer does not renew it. If there is no longer time to do so,
* it starts now and is flagged as needing a short bridge extension.
* it starts as early as it can and is flagged as needing a bridge extension.
* 2. Everything else is sequenced into the earliest month its workstream has
* capacity: lines with a known saving before those without (spend not yet
* captured), then by wave (quick wins first), then by size of saving. Each
* business unit is its own workstream (security, infrastructure and ops
* migrations are run by different teams), with a set number in parallel.
* 3. Savings start at cutover, or at contract end if that comes later —
* captured), then by wave (quick wins first), then by size of saving.
* Each business unit is its own workstream, with a set number in parallel.
* 3. "Starts after" dependencies hold for everything that is not pinned.
* 4. Savings start at cutover, or at contract end if that comes later —
* the customer keeps paying for a contract until it expires.
*/
@@ -25,6 +26,7 @@ export const DURATION_BY_EASE: Record<number, number> = { 5: 2, 4: 3, 3: 5, 2: 8
export const DEFAULT_PARALLEL = 2;
/** Earliest month each wave may start. */
const WAVE_OFFSET: Record<1 | 2 | 3, number> = { 1: 0, 2: 3, 3: 6 };
const MAX_MONTH = 240;
export type PlanItem = {
id: string;
@@ -38,11 +40,20 @@ export type PlanItem = {
/** Month the contract runs out (the bar position is the end of that month), null if unknown. */
contractEnd: number | null;
savingsFrom: number;
/** Net annual benefit of the line (after its share of our subscription). */
annualSavings: number;
/** Cost removed from the customer each year once retired. */
grossRemoved: number;
/** One-time cost: migration plus attributed services. */
migrationCost: number;
anchor: "contract" | "sequenced";
anchor: "contract" | "sequenced" | "pinned";
/** Contract ends before the migration can finish. */
bridge: boolean;
/** A pinned start sits before a predecessor finishes. */
conflict: boolean;
/** Seller has overridden start, duration or dependencies. */
edited: boolean;
after: string[];
};
export type Plan = {
@@ -53,19 +64,24 @@ export type Plan = {
completion: number;
/** Month full run-rate savings are reached. */
fullRunRate: number;
/** Annual run-rate savings in effect during each month, 0..span-1. */
/** Annual run-rate net savings in effect during each month, 0..span-1. */
ramp: number[];
span: number;
/** Savings actually realised inside the planning horizon once ramp-up is counted. */
/** Net savings realised inside the planning horizon once ramp-up is counted. */
realizedInHorizon: number;
/** The same horizon if every saving started on day one — the flat model the headline uses. */
/** The same horizon if every saving started on day one — the flat model. */
flatInHorizon: number;
bridgeCount: number;
conflictCount: number;
unmappedCount: number;
};
export function planStart(today = new Date()) {
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
}
const monthIndex = (start: Date, iso: string) => {
const d = new Date(`${iso}T00:00:00Z`);
const d = new Date(`${iso.length === 7 ? `${iso}-01` : iso}T00:00:00Z`);
if (isNaN(+d)) return null;
return (d.getUTCFullYear() - start.getUTCFullYear()) * 12 + (d.getUTCMonth() - start.getUTCMonth());
};
@@ -75,6 +91,11 @@ export function monthDate(startIso: string, n: number) {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
}
/** Month n as yyyy-mm, the form a pinned start is stored in. */
export function monthKey(startIso: string, n: number) {
return monthDate(startIso, n).toISOString().slice(0, 7);
}
export function monthLabel(startIso: string, n: number, long = false) {
return monthDate(startIso, n).toLocaleDateString("en-US", { month: long ? "long" : "short", year: "numeric", timeZone: "UTC" });
}
@@ -84,70 +105,121 @@ export function quarterLabel(startIso: string, n: number) {
return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`;
}
export function defaultDuration(c: Pick<Candidate, "switchEase">) {
return DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5;
}
export function buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = new Date()): Plan {
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 1));
const start = planStart(today);
const startIso = start.toISOString().slice(0, 10);
const prodById = new Map(catalog.products.map((p) => [p.id, p]));
const buById = new Map(catalog.businessUnits.map((b) => [b.id, b]));
const mapped = r.included.filter((c) => c.productIds.length);
const mappedIds = new Set(mapped.map((c) => c.id));
const parallel = Math.max(1, Math.round(a.planParallel ?? DEFAULT_PARALLEL));
const load = new Map<string, number[]>();
const track = (ws: string) => { if (!load.has(ws)) load.set(ws, []); return load.get(ws)!; };
const occupy = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) l[m] = (l[m] ?? 0) + 1; };
const fits = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) if ((l[m] ?? 0) >= parallel) return false; return true; };
const items: (PlanItem & { duration: number; workstream: string })[] = [];
const base = (c: (typeof mapped)[number]) => {
const m = r.metrics.get(c.id)!;
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
return {
id: c.id,
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
color: bu?.color ?? "var(--text-muted)",
bu: bu?.short ?? "",
workstream: bu?.id ?? "other",
wave: m.wave,
annualSavings: m.annualSavings,
migrationCost: c.migrationCost,
duration: DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5,
};
type Work = {
c: Candidate; ws: string; duration: number; after: string[];
due: number | null; pinned: number | null;
base: Omit<PlanItem, "start" | "end" | "savingsFrom" | "anchor" | "bridge" | "conflict">;
};
// 1. Contract-anchored work is fixed first; it is dictated by the customer's paper, not our preference.
const flexible: typeof mapped = [];
for (const c of mapped) {
const work: Work[] = mapped.map((c) => {
const m = r.metrics.get(c.id)!;
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
const ce = c.renewalDate ? monthIndex(start, c.renewalDate) : null;
if (ce === null) { flexible.push(c); continue; }
const b = base(c);
const due = ce + 1; // cut over by the end of the contract month
const bridge = due - b.duration < 0;
const s = bridge ? 0 : due - b.duration;
const e = s + b.duration;
occupy(b.workstream, s, e);
items.push({
...b, start: s, end: e, contractEnd: ce, anchor: "contract", bridge,
savingsFrom: Math.max(e, due),
const pinnedRaw = c.plan?.start ? monthIndex(start, c.plan.start) : null;
const after = (c.plan?.after ?? []).filter((id) => id !== c.id && mappedIds.has(id));
return {
c,
ws: bu?.id ?? "other",
duration: Math.max(1, Math.round(c.plan?.duration ?? defaultDuration(c))),
after,
due: ce === null ? null : ce + 1, // cut over by the end of the contract month
pinned: pinnedRaw === null ? null : Math.max(0, pinnedRaw),
base: {
id: c.id,
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
color: bu?.color ?? "var(--text-muted)",
bu: bu?.short ?? "",
wave: m.wave,
contractEnd: ce,
annualSavings: m.annualSavings,
grossRemoved: m.grossRemoved,
migrationCost: m.oneTime,
edited: !!(c.plan?.start || c.plan?.duration || c.plan?.after?.length),
after,
},
};
});
const placed = new Map<string, PlanItem>();
const place = (w: Work, s: number, anchor: PlanItem["anchor"], bridge: boolean) => {
const e = s + w.duration;
occupy(w.ws, s, e);
placed.set(w.c.id, {
...w.base, start: s, end: e, anchor, bridge, conflict: false,
savingsFrom: w.due === null ? e : Math.max(e, w.due),
});
};
const depsEnd = (w: Work) => Math.max(0, ...w.after.map((id) => placed.get(id)?.end ?? 0));
const depsReady = (w: Work) => w.after.every((id) => placed.has(id));
// 0. Pinned work is fixed first; it is the seller's decision.
for (const w of work.filter((x) => x.pinned !== null)) {
place(w, w.pinned!, "pinned", w.due !== null && w.pinned! + w.duration > w.due);
}
// 2. The rest is sequenced into capacity: money first, then quick wins, then the biggest lines.
flexible.sort((x, y) => {
const mx = r.metrics.get(x.id)!, my = r.metrics.get(y.id)!;
// 1–2. Contract-anchored work by due date, then the rest by value. A line waits
// until everything it depends on is placed; a dependency cycle is broken by
// ignoring the dependencies of whatever is left.
const metric = (w: Work) => r.metrics.get(w.c.id)!;
const queue = work.filter((w) => w.pinned === null).sort((x, y) => {
if ((x.due === null) !== (y.due === null)) return x.due === null ? 1 : -1;
if (x.due !== null && y.due !== null) return x.due - y.due;
const mx = metric(x), my = metric(y);
return Number(my.annualSavings > 0) - Number(mx.annualSavings > 0)
|| mx.wave - my.wave || my.annualSavings - mx.annualSavings || my.score - mx.score;
});
for (const c of flexible) {
const b = base(c);
let s = WAVE_OFFSET[b.wave];
while (!fits(b.workstream, s, s + b.duration) && s < 240) s++;
occupy(b.workstream, s, s + b.duration);
items.push({ ...b, start: s, end: s + b.duration, contractEnd: null, anchor: "sequenced", bridge: false, savingsFrom: s + b.duration });
let ignoreDeps = false;
while (queue.length) {
const i = queue.findIndex((w) => ignoreDeps || depsReady(w));
if (i === -1) { ignoreDeps = true; continue; }
const w = queue.splice(i, 1)[0];
const earliest = ignoreDeps ? 0 : depsEnd(w);
if (w.due !== null) {
const ideal = w.due - w.duration;
if (ideal >= earliest) {
place(w, ideal, "contract", false);
} else {
let s = earliest;
while (!fits(w.ws, s, s + w.duration) && s < MAX_MONTH) s++;
place(w, s, "contract", s + w.duration > w.due);
}
continue;
}
let s = Math.max(earliest, WAVE_OFFSET[metric(w).wave]);
while (!fits(w.ws, s, s + w.duration) && s < MAX_MONTH) s++;
place(w, s, "sequenced", false);
}
items.sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
// A pinned line that starts before a predecessor finishes is flagged, not moved.
for (const w of work) {
const it = placed.get(w.c.id)!;
if (it.anchor === "pinned" && w.after.some((id) => (placed.get(id)?.end ?? 0) > it.start)) it.conflict = true;
}
const items = [...placed.values()].sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
const completion = Math.max(0, ...items.map((i) => i.end));
const fullRunRate = Math.max(0, ...items.map((i) => i.savingsFrom));
@@ -158,13 +230,11 @@ export function buildPlan(a: Assessment, r: Rollup, catalog: Catalog, today = ne
const realizedInHorizon = ramp.slice(0, horizon).reduce((s, v) => s + v / 12, 0);
const flatInHorizon = items.reduce((s, i) => s + i.annualSavings, 0) * a.horizonYears;
// duration and workstream were only needed for scheduling.
const clean: PlanItem[] = items.map(({ duration: _d, workstream: _w, ...rest }) => rest);
return {
startIso, items: clean, completion, fullRunRate, ramp: ramp.slice(0, span), span,
startIso, items, completion, fullRunRate, ramp: ramp.slice(0, span), span,
realizedInHorizon, flatInHorizon,
bridgeCount: clean.filter((i) => i.bridge).length,
bridgeCount: items.filter((i) => i.bridge).length,
conflictCount: items.filter((i) => i.conflict).length,
unmappedCount: r.included.length - mapped.length,
};
}

View File

@@ -64,8 +64,68 @@ export type Candidate = {
notes: string;
/** Excluded from rollups and the customer report without being deleted. */
included: boolean;
/**
* Total cost of ownership beyond licence. `tcoDefaults` is the knowledge-base
* baseline copied onto the line; `tco` holds the seller's overrides (null =
* use the default). Admin labour is in FTEs, priced at the assessment's FTE cost.
*/
tcoDefaults?: TcoDefaults;
tco?: { infra: number | null; adminFte: number | null; other: number | null };
/** Optional per-line pricing for our replacement (internal). Blank = share of the deal-level figure. */
investment?: { annual: number | null; services: number | null };
/** Seller overrides to the generated implementation plan. */
plan?: PlanOverride;
/** Set when a mapped product is already entitled but undeployed: no new subscription is needed. */
alreadyEntitled?: string;
};
export type TcoDefaults = {
/** Infrastructure, as % of licence spend. */
infraPct: number;
/** Admin labour in FTEs. */
adminFte: number;
/** Training, integrations and other run costs, as % of licence spend. */
otherPct: number;
};
export type PlanOverride = {
/** Pinned start month, yyyy-mm. */
start?: string;
/** Migration length in months. */
duration?: number;
/** Candidate ids that must finish before this one starts. */
after?: string[];
};
export type Finance = {
/** Annual discount rate for NPV, %. */
discountRate: number;
/** Fully loaded annual cost of one admin FTE, USD. */
fteCost: number;
/** Our proposal, internal only: annual subscription and one-time services. */
investment: { annual: number; services: number };
};
export type Scenario = {
id: string;
name: string;
kind: "preset" | "custom";
/** Candidate ids left out of this scenario. Presets follow the base assessment's scope. */
excluded?: string[];
/** Multiplier on the cost each line removes, e.g. 0.75 for conservative. */
valueFactor: number;
/** Parallel migrations per business unit; absent = the assessment's own setting. */
parallel?: number;
/** Replaces the deal-level investment for this scenario (internal). */
investment?: { annual: number; services: number };
};
export type DealStatus = "open" | "won" | "lost" | "paused";
export type Assessment = {
id: string;
version: number;
@@ -80,6 +140,16 @@ export type Assessment = {
candidates: Candidate[];
/** Implementation plan: migrations each business-unit workstream runs in parallel (default 2). */
planParallel?: number;
finance?: Finance;
/** Custom scenarios; the three presets are derived, not stored. */
scenarios?: Scenario[];
/** Scenario the report, Word file and deck present ("expected" = the assessment as it stands). */
presentedScenario?: string;
/** Adds a side-by-side scenario comparison to the customer deliverables. */
showScenarioComparison?: boolean;
status?: DealStatus;
/** Customer logo as a PNG or JPEG data URL. */
customerLogo?: string;
/** Imported account dossier. Internal context; never rendered to the customer. */
accountIntel?: AccountIntel | null;
createdAt: string;
@@ -88,9 +158,33 @@ export type Assessment = {
export type AssessmentSummary = Pick<
Assessment,
"id" | "customerName" | "preparedBy" | "updatedAt" | "createdAt"
"id" | "customerName" | "preparedBy" | "updatedAt" | "createdAt" | "status"
> & { candidateCount: number; annualSpend: number; annualSavings: number };
/* ------------------------------------------------------------ branding */
export type Branding = {
companyName: string;
/** Hex accent colour used on reports and decks. */
accent: string;
/** Our logo as a PNG or JPEG data URL. */
logo: string;
};
/* ------------------------------------------------------------ snapshots */
export type SnapshotMeta = {
id: string;
assessmentId: string;
label: string;
trigger: "manual" | "word" | "deck" | "print" | "restore";
createdAt: string;
/** Headline at the time, so the list reads without loading every snapshot. */
headline: { netAnnual: number; horizonNet: number; tools: number };
};
export type Snapshot = SnapshotMeta & { assessment: Assessment };
/* ------------------------------------------------- competitive knowledge base */
/** One recorded use of a knowledge-base entry in a real assessment. */
@@ -138,6 +232,13 @@ export type KbEntry = {
competitive: boolean;
/** Pinned entries are curated: auto-learning never overwrites their defaults. */
pinned: boolean;
/** TCO baseline copied onto new candidates; absent = the global defaults. */
tco?: TcoDefaults;
/** Curator who last reviewed the entry, and when (ISO date). */
owner?: string;
reviewedAt?: string;
/** Curator's override of the computed confidence. */
confidenceOverride?: "high" | "medium" | "low";
origin: "catalog" | "learned" | "manual";
observations: KbObservation[];
createdAt: string;
@@ -148,6 +249,8 @@ export type KnowledgeBase = {
version: number;
updatedAt: string;
entries: KbEntry[];
/** Duplicate pairs a curator said are genuinely different ("idA|idB", sorted). */
distinctPairs?: string[];
};
/* ------------------------------------------------------ account intelligence */