Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f42b0274d | |||
| 93007a1053 | |||
| 4496312845 |
BIN
Brand Guidelines.pdf
Normal file
BIN
Brand Guidelines.pdf
Normal file
Binary file not shown.
@@ -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": [
|
||||
|
||||
@@ -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
5
data/settings.json
Normal 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
58
package-lock.json
generated
@@ -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",
|
||||
|
||||
11
package.json
11
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
133
scripts/test-finance.mjs
Normal 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);
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
243
src/app/api/assessments/[id]/deck/route.ts
Normal file
243
src/app/api/assessments/[id]/deck/route.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import PptxGenJS from "pptxgenjs";
|
||||
import { createSnapshot, getAssessment, getBranding, getCatalog } from "@/lib/store";
|
||||
import { customerSafe } from "@/lib/redact";
|
||||
import { deliverable } from "@/lib/deliverable";
|
||||
import { allScenarios, evaluate } from "@/lib/finance";
|
||||
import { WAVE_LABEL } from "@/lib/scoring";
|
||||
import { monthLabel, quarterLabel } from "@/lib/timeline";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
import { imageSize } from "@/lib/images";
|
||||
|
||||
// Brand palette (light theme). One approach per deck: white backgrounds throughout.
|
||||
const DARK = "1B1D36", NAVY = "005C8A", AZURE = "0098C7", AQUA = "007B8C", GREEN = "23800A", PURPLE = "6C4B94";
|
||||
const MUTED = "53565A", RULE = "D9DAE0", WELL = "F3F4F7";
|
||||
const FONT = "Arial";
|
||||
|
||||
type Slide = PptxGenJS.Slide;
|
||||
|
||||
/**
|
||||
* Executive deck for the presented scenario. Built from the redacted
|
||||
* assessment, with net figures only — our investment is never itemized.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const stored = await getAssessment(id);
|
||||
if (!stored) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const [catalog, brand] = await Promise.all([getCatalog(), getBranding()]);
|
||||
const a = customerSafe(stored);
|
||||
const { e, summary, nextSteps } = deliverable(a, catalog);
|
||||
const { r, cash, plan } = e;
|
||||
const ACCENT = (brand.accent || `#${NAVY}`).replace("#", "").toUpperCase();
|
||||
const prod = (pid: string) => catalog.products.find((p) => p.id === pid)?.name ?? pid;
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = "LAYOUT_WIDE"; // 13.33 × 7.5 in
|
||||
pptx.title = `${a.customerName} — Software portfolio rationalization`;
|
||||
pptx.company = brand.companyName || "Converge";
|
||||
|
||||
const logo = (s: Slide, dataUrl: string, x: number, y: number, maxW: number, maxH: number) => {
|
||||
const size = imageSize(dataUrl);
|
||||
if (!size) return;
|
||||
const scale = Math.min(maxW / size.w, maxH / size.h);
|
||||
s.addImage({ data: dataUrl.replace(/^data:/, ""), x, y, w: size.w * scale, h: size.h * scale });
|
||||
};
|
||||
|
||||
const frame = (title: string, kicker?: string) => {
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: "FFFFFF" };
|
||||
s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 13.33, h: 0.08, fill: { color: ACCENT }, line: { color: ACCENT } });
|
||||
if (kicker) s.addText(kicker.toUpperCase(), { x: 0.6, y: 0.35, w: 10, h: 0.3, fontFace: FONT, fontSize: 10, bold: true, color: ACCENT, charSpacing: 2 });
|
||||
s.addText(title, { x: 0.6, y: 0.62, w: 12, h: 0.6, fontFace: FONT, fontSize: 26, bold: true, color: DARK });
|
||||
s.addText(`${a.customerName} · ${e.scenario.name} scenario`, { x: 0.6, y: 7.05, w: 8, h: 0.25, fontFace: FONT, fontSize: 9, color: MUTED });
|
||||
if (brand.logo) logo(s, brand.logo, 11.4, 6.98, 1.4, 0.34);
|
||||
return s;
|
||||
};
|
||||
|
||||
/* 1 — title */
|
||||
{
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: "FFFFFF" };
|
||||
s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: 0.35, h: 7.5, fill: { color: ACCENT }, line: { color: ACCENT } });
|
||||
let lx = 1.0;
|
||||
if (brand.logo) { logo(s, brand.logo, lx, 0.8, 2.4, 0.7); lx += 2.8; }
|
||||
if (a.customerLogo) logo(s, a.customerLogo, lx, 0.8, 2.4, 0.7);
|
||||
s.addText("SOFTWARE PORTFOLIO RATIONALIZATION", { x: 1.0, y: 2.6, w: 11, h: 0.4, fontFace: FONT, fontSize: 14, bold: true, color: ACCENT, charSpacing: 3 });
|
||||
s.addText(a.customerName, { x: 1.0, y: 3.0, w: 11, h: 1.1, fontFace: FONT, fontSize: 44, bold: true, color: DARK });
|
||||
const meta = [a.preparedFor && `Prepared for ${a.preparedFor}`, a.preparedBy && `Prepared by ${a.preparedBy}`,
|
||||
new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })].filter(Boolean).join(" · ");
|
||||
s.addText(meta, { x: 1.0, y: 4.2, w: 11, h: 0.4, fontFace: FONT, fontSize: 14, color: MUTED });
|
||||
}
|
||||
|
||||
/* 2 — headline */
|
||||
{
|
||||
const s = frame("The value of consolidating", "Executive summary");
|
||||
s.addText(summary, { x: 0.6, y: 1.35, w: 12.1, h: 1.3, fontFace: FONT, fontSize: 14, color: MUTED, valign: "top" });
|
||||
const tiles: [string, string, string][] = [
|
||||
["Net annual benefit", money(cash.runRateNet, true), "At full run-rate"],
|
||||
[`${a.horizonYears}-year net benefit`, money(cash.horizonNet, true), `NPV ${money(cash.npv, true)}`],
|
||||
["Payback", cash.paybackMonth === null ? "Beyond horizon" : cash.paybackMonth === 0 ? "Immediate" : `Month ${cash.paybackMonth}`, cash.irr === null ? "" : `IRR ${Math.round(cash.irr)}%`],
|
||||
["Tool footprint", `${r.toolsRetired} → ${r.toolsAfter}`, `${r.vendorsRetired} vendors → ${r.vendorsAfter}`],
|
||||
];
|
||||
tiles.forEach(([k, v, sub], i) => {
|
||||
const x = 0.6 + i * 3.1;
|
||||
s.addShape(pptx.ShapeType.rect, { x, y: 3.0, w: 2.9, h: 2.1, fill: { color: WELL }, line: { color: WELL } });
|
||||
s.addText(k.toUpperCase(), { x: x + 0.2, y: 3.15, w: 2.6, h: 0.3, fontFace: FONT, fontSize: 10, bold: true, color: MUTED });
|
||||
s.addText(v, { x: x + 0.2, y: 3.55, w: 2.6, h: 0.8, fontFace: FONT, fontSize: 30, bold: true, color: i === 0 ? GREEN : i === 1 ? ACCENT : DARK });
|
||||
if (sub) s.addText(sub, { x: x + 0.2, y: 4.45, w: 2.6, h: 0.3, fontFace: FONT, fontSize: 12, color: MUTED });
|
||||
});
|
||||
s.addText(`Current cost of ownership ${money(r.addressableTco)} a year across ${r.toolsRetired} tools.`, { x: 0.6, y: 5.5, w: 12, h: 0.35, fontFace: FONT, fontSize: 13, color: DARK });
|
||||
}
|
||||
|
||||
/* 3 — landscape & overlap */
|
||||
{
|
||||
const s = frame("Where the customer pays more than once", "Current landscape");
|
||||
const rows = [...r.byDomain].sort((x, y) => y.count - x.count || y.spend - x.spend).slice(0, 12);
|
||||
const head = ["Capability", "Tools", "Vendors", "Current cost", "Net annual benefit"].map((t) => ({ text: t, options: { bold: true, color: "FFFFFF", fill: { color: DARK } } }));
|
||||
const body = rows.map((d) => [
|
||||
{ text: d.name, options: {} },
|
||||
{ text: String(d.count), options: { bold: d.count > 1, color: d.count > 1 ? "FFFFFF" : DARK, fill: { color: d.count >= 3 ? NAVY : d.count === 2 ? AZURE : "FFFFFF" }, align: "center" as const } },
|
||||
{ text: String(d.vendors), options: { align: "center" as const } },
|
||||
{ text: money(d.spend), options: { align: "right" as const } },
|
||||
{ text: money(d.savings), options: { align: "right" as const, color: GREEN, bold: true } },
|
||||
]);
|
||||
s.addTable([head, ...body], { x: 0.6, y: 1.4, w: 12.1, colW: [5.3, 1.2, 1.2, 2.2, 2.2], fontFace: FONT, fontSize: 12, color: DARK, border: { type: "solid", pt: 0.5, color: RULE }, rowH: 0.36 });
|
||||
const redundant = r.byDomain.filter((d) => d.count > 1);
|
||||
if (redundant.length) {
|
||||
s.addText(`${redundant.length} categories run more than one tool — ${money(redundant.reduce((t, d) => t + d.spend, 0))} of cost where capability is duplicated.`,
|
||||
{ x: 0.6, y: 6.5, w: 12, h: 0.35, fontFace: FONT, fontSize: 12, italic: true, color: MUTED });
|
||||
}
|
||||
}
|
||||
|
||||
/* 4 — value by business unit */
|
||||
{
|
||||
const s = frame("Where the value lands", "Value by business unit");
|
||||
// Horizontal bar charts draw bottom-up, so reverse to put the largest at the top.
|
||||
const bus = r.byBu.filter((b) => b.savings > 0).reverse();
|
||||
if (bus.length) {
|
||||
s.addChart(pptx.ChartType.bar, [{ name: "Net annual benefit", labels: bus.map((b) => b.name), values: bus.map((b) => Math.round(b.savings)) }], {
|
||||
x: 0.6, y: 1.4, w: 7.4, h: 5.3, barDir: "bar", chartColors: bus.map((b) => b.color.replace("#", "")),
|
||||
catAxisLabelFontSize: 11, valAxisLabelFontSize: 10, valAxisLabelFormatCode: "$#,##0,,\"M\"",
|
||||
showValue: true, dataLabelFormatCode: "$#,##0,,.0\"M\"", dataLabelFontSize: 10,
|
||||
valGridLine: { color: RULE, size: 0.5 }, catGridLine: { style: "none" },
|
||||
});
|
||||
}
|
||||
const list = r.byBu.slice(0, 7).map((b) => ({
|
||||
text: `${b.name}\n`, options: { bold: true, fontSize: 12, color: DARK, breakLine: false },
|
||||
}));
|
||||
s.addText(r.byBu.slice(0, 7).flatMap((b, i) => [
|
||||
list[i],
|
||||
{ text: `${b.count} tool${b.count === 1 ? "" : "s"} → ${b.productIds.map(prod).slice(0, 3).join(", ")}${b.productIds.length > 3 ? "…" : ""}\n`, options: { fontSize: 10.5, color: MUTED, breakLine: false } },
|
||||
]), { x: 8.3, y: 1.4, w: 4.4, h: 5.3, fontFace: FONT, valign: "top", paraSpaceAfter: 6 });
|
||||
}
|
||||
|
||||
/* 5 — roadmap */
|
||||
{
|
||||
const s = frame("A sequence that de-risks the move", "Rationalization roadmap");
|
||||
([1, 2, 3] as const).forEach((w, k) => {
|
||||
const items = r.byWave[w];
|
||||
const x = 0.6 + k * 4.1;
|
||||
const color = [ACCENT, PURPLE, AQUA][k];
|
||||
s.addShape(pptx.ShapeType.rect, { x, y: 1.4, w: 3.9, h: 0.08, fill: { color }, line: { color } });
|
||||
s.addText(WAVE_LABEL[w], { x, y: 1.55, w: 3.9, h: 0.4, fontFace: FONT, fontSize: 14, bold: true, color: DARK });
|
||||
const savings = items.reduce((t, c) => t + r.metrics.get(c.id)!.annualSavings, 0);
|
||||
s.addText(`${money(savings, true)} net per year · ${items.length} tools`, { x, y: 1.95, w: 3.9, h: 0.3, fontFace: FONT, fontSize: 11, color: GREEN, bold: true });
|
||||
const lines = items.slice(0, 9).map((c) => ({
|
||||
text: `${`${c.incumbentVendor} ${c.incumbentProduct}`.trim()} → ${c.productIds.map(prod).join(", ") || "—"}`,
|
||||
options: { bullet: true, fontSize: 10.5, color: DARK, breakLine: true },
|
||||
}));
|
||||
if (items.length > 9) lines.push({ text: `+ ${items.length - 9} more`, options: { bullet: false, fontSize: 10, color: MUTED, breakLine: true } });
|
||||
s.addText(lines.length ? lines : [{ text: "Nothing in this wave.", options: { bullet: false, fontSize: 10.5, color: MUTED, breakLine: true } }],
|
||||
{ x, y: 2.35, w: 3.9, h: 4.4, fontFace: FONT, valign: "top", paraSpaceAfter: 4 });
|
||||
});
|
||||
}
|
||||
|
||||
/* 6 — implementation timeline */
|
||||
{
|
||||
const s = frame(`${plan.completion} months to full run-rate`, "Implementation timeline");
|
||||
const items = plan.items.slice(0, 20);
|
||||
const span = Math.max(plan.span, ...items.map((i) => i.end + 1));
|
||||
const X0 = 3.6, X1 = 12.7, Y0 = 1.75, rowH = Math.min(0.24, 4.9 / Math.max(1, items.length));
|
||||
const mx = (m: number) => X0 + ((X1 - X0) * m) / span;
|
||||
const firstMonth = new Date(`${plan.startIso}T00:00:00Z`).getUTCMonth();
|
||||
for (let m = 0; m <= span; m++) {
|
||||
if ((firstMonth + m) % 3) continue;
|
||||
s.addShape(pptx.ShapeType.line, { x: mx(m), y: Y0 - 0.05, w: 0, h: rowH * items.length + 0.1, line: { color: RULE, width: 0.5 } });
|
||||
if ((firstMonth + m) % 6 === 0) s.addText(quarterLabel(plan.startIso, m), { x: mx(m) - 0.5, y: Y0 - 0.35, w: 1, h: 0.25, fontFace: FONT, fontSize: 8.5, color: MUTED, align: "center" });
|
||||
}
|
||||
items.forEach((it, k) => {
|
||||
const y = Y0 + k * rowH;
|
||||
s.addText(it.label, { x: 0.6, y, w: 2.9, h: rowH, fontFace: FONT, fontSize: 9, color: DARK, valign: "middle" });
|
||||
s.addShape(pptx.ShapeType.roundRect, { x: mx(it.start), y: y + rowH * 0.2, w: Math.max(0.05, mx(it.end) - mx(it.start)), h: rowH * 0.6, rectRadius: 0.03,
|
||||
fill: { color: it.color.startsWith("#") ? it.color.slice(1) : ACCENT }, line: { color: it.color.startsWith("#") ? it.color.slice(1) : ACCENT } });
|
||||
if (it.contractEnd !== null) {
|
||||
s.addShape(pptx.ShapeType.diamond, { x: mx(it.contractEnd + 1) - 0.06, y: y + rowH * 0.25, w: 0.12, h: rowH * 0.5, fill: { color: "FFFFFF" }, line: { color: DARK, width: 1 } });
|
||||
}
|
||||
});
|
||||
const more = plan.items.length - items.length;
|
||||
s.addText(`${L(0)} – ${L(plan.completion - 1)} · ${plan.items.length} projects${more > 0 ? ` (first ${items.length} shown)` : ""} · ◇ contract end · savings from ${L(plan.fullRunRate)} at full run-rate`,
|
||||
{ x: 0.6, y: 6.6, w: 12, h: 0.3, fontFace: FONT, fontSize: 10, color: MUTED });
|
||||
}
|
||||
|
||||
/* 7 — cash flow */
|
||||
{
|
||||
const s = frame("Net benefit year by year", "Cash flow");
|
||||
s.addChart(pptx.ChartType.bar, [
|
||||
{ name: "Net", labels: cash.years.map((y) => `Year ${y.year}`), values: cash.years.map((y) => Math.round(y.net)) },
|
||||
{ name: "Cumulative", labels: cash.years.map((y) => `Year ${y.year}`), values: cash.years.map((y) => Math.round(y.cumulative)) },
|
||||
], {
|
||||
x: 0.6, y: 1.4, w: 6.8, h: 5.2, barDir: "col", barGrouping: "clustered", chartColors: [ACCENT, GREEN],
|
||||
showLegend: true, legendPos: "b", legendFontSize: 11, valAxisLabelFormatCode: "$#,##0,,\"M\"", valAxisLabelFontSize: 10,
|
||||
catAxisLabelFontSize: 11, valGridLine: { color: RULE, size: 0.5 },
|
||||
});
|
||||
const head = ["", "Recurring change", "One-time", "Net", "Cumulative"].map((t) => ({ text: t, options: { bold: true, color: "FFFFFF", fill: { color: DARK } } }));
|
||||
const body = cash.years.map((y) => [
|
||||
{ text: `Year ${y.year}`, options: { bold: true } },
|
||||
{ text: money(y.removed - y.subscription), options: { align: "right" as const } },
|
||||
{ text: y.oneTime ? `−${money(y.oneTime)}` : "—", options: { align: "right" as const } },
|
||||
{ text: money(y.net), options: { align: "right" as const, bold: true, color: y.net >= 0 ? GREEN : "B42318" } },
|
||||
{ text: money(y.cumulative), options: { align: "right" as const } },
|
||||
]);
|
||||
s.addTable([head, ...body], { x: 7.7, y: 1.6, w: 5.0, colW: [0.8, 1.2, 0.95, 1.0, 1.05], fontFace: FONT, fontSize: 10.5, color: DARK, border: { type: "solid", pt: 0.5, color: RULE }, rowH: 0.4 });
|
||||
s.addText(`NPV ${money(cash.npv)} at ${a.finance?.discountRate ?? 10}% · payback ${cash.paybackMonth ? `month ${cash.paybackMonth}` : "n/a"} · one-time ${money(cash.oneTimeTotal)}`,
|
||||
{ x: 7.7, y: 3.9, w: 5.0, h: 0.5, fontFace: FONT, fontSize: 11, color: MUTED });
|
||||
}
|
||||
|
||||
/* 8 — scenarios (optional) */
|
||||
if (a.showScenarioComparison) {
|
||||
const s = frame("Scenarios considered", "Sensitivity");
|
||||
const head = ["Scenario", "Tools", "Net / year", `${a.horizonYears}-year net`, "NPV", "Payback"].map((t) => ({ text: t, options: { bold: true, color: "FFFFFF", fill: { color: DARK } } }));
|
||||
const body = allScenarios(a).map((sc) => {
|
||||
const x = evaluate(a, catalog, sc);
|
||||
const on = sc.id === e.scenario.id;
|
||||
const o = { bold: on, color: DARK };
|
||||
return [
|
||||
{ text: `${sc.name}${on ? " (presented)" : ""}`, options: o },
|
||||
{ text: String(x.r.toolsRetired), options: { ...o, align: "right" as const } },
|
||||
{ text: money(x.cash.runRateNet), options: { ...o, align: "right" as const } },
|
||||
{ text: money(x.cash.horizonNet), options: { ...o, align: "right" as const } },
|
||||
{ text: money(x.cash.npv), options: { ...o, align: "right" as const } },
|
||||
{ text: x.cash.paybackMonth ? monthsLabel(x.cash.paybackMonth) : "—", options: { ...o, align: "right" as const } },
|
||||
];
|
||||
});
|
||||
s.addTable([head, ...body], { x: 0.6, y: 1.5, w: 12.1, colW: [3.6, 1.2, 2, 2, 1.9, 1.4], fontFace: FONT, fontSize: 13, border: { type: "solid", pt: 0.5, color: RULE }, rowH: 0.5 });
|
||||
}
|
||||
|
||||
/* 9 — next steps */
|
||||
{
|
||||
const s = frame("Next steps", "Recommended actions");
|
||||
s.addText(nextSteps.map((t, i) => ({ text: t, options: { bullet: { type: "number" as const }, breakLine: i < nextSteps.length - 1, fontSize: 15, color: DARK } })),
|
||||
{ x: 0.6, y: 1.5, w: 12, h: 5.2, fontFace: FONT, valign: "top", paraSpaceAfter: 12 });
|
||||
}
|
||||
|
||||
const buf = (await pptx.write({ outputType: "nodebuffer" })) as Buffer;
|
||||
// What the customer receives is frozen, so it can always be reproduced.
|
||||
await createSnapshot(stored, `Deck export — ${new Date().toLocaleDateString("en-US")}`, "deck").catch(() => {});
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"Content-Disposition": `attachment; filename="${a.id}-executive-deck.pptx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
ImageRun,
|
||||
Packer,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
@@ -12,14 +13,19 @@ import {
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import { getAssessment, getCatalog } from "@/lib/store";
|
||||
import { createSnapshot, getAssessment, getBranding, getCatalog } from "@/lib/store";
|
||||
import { customerSafe } from "@/lib/redact";
|
||||
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import { WAVE_BLURB, WAVE_LABEL } from "@/lib/scoring";
|
||||
import { allScenarios, evaluate } from "@/lib/finance";
|
||||
import { deliverable } from "@/lib/deliverable";
|
||||
import { monthLabel } from "@/lib/timeline";
|
||||
import { imageSize } from "@/lib/images";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
|
||||
const INK = "0B0B0B";
|
||||
const MUTED = "52514E";
|
||||
const ACCENT = "2A78D6";
|
||||
// Brand palette: Dark Blue for text, Navy as the default accent.
|
||||
const INK = "1B1D36";
|
||||
const MUTED = "53565A";
|
||||
const DEFAULT_ACCENT = "005C8A";
|
||||
|
||||
function cell(text: string, opts: { bold?: boolean; fill?: string; align?: "left" | "right" } = {}) {
|
||||
return new TableCell({
|
||||
@@ -40,7 +46,7 @@ function table(headers: string[], rows: string[][], aligns: ("left" | "right")[]
|
||||
rows: [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: headers.map((h, i) => cell(h, { bold: true, fill: "F0EFEC", align: aligns[i] })),
|
||||
children: headers.map((h, i) => cell(h, { bold: true, fill: "EEF1F6", align: aligns[i] })),
|
||||
}),
|
||||
...rows.map((r) => new TableRow({ children: r.map((v, i) => cell(v, { align: aligns[i] })) })),
|
||||
],
|
||||
@@ -54,54 +60,97 @@ const h2 = (t: string) =>
|
||||
const p = (t: string, color = MUTED) =>
|
||||
new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: t, size: 21, color })] });
|
||||
|
||||
function logoRun(dataUrl: string, maxW: number, maxH: number) {
|
||||
const size = imageSize(dataUrl);
|
||||
if (!size) return null;
|
||||
const scale = Math.min(maxW / size.w, maxH / size.h);
|
||||
return new ImageRun({
|
||||
type: size.type,
|
||||
data: Buffer.from(dataUrl.split(",")[1], "base64"),
|
||||
transformation: { width: Math.round(size.w * scale), height: Math.round(size.h * scale) },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const stored = await getAssessment(id);
|
||||
if (!stored) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
// Same redaction as the on-screen report: the Word file is a customer artefact.
|
||||
const a = customerSafe(stored);
|
||||
const catalog = await getCatalog();
|
||||
const r = rollup(a, catalog);
|
||||
const [catalog, brand] = await Promise.all([getCatalog(), getBranding()]);
|
||||
const ACCENT = (brand.accent || `#${DEFAULT_ACCENT}`).replace("#", "").toUpperCase();
|
||||
const { e, summary, nextSteps } = deliverable(a, catalog);
|
||||
const { r, cash, plan } = e;
|
||||
const prod = new Map(catalog.products.map((x) => [x.id, x]));
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
const payback = cash.paybackMonth === null ? "Beyond horizon" : cash.paybackMonth === 0 ? "Immediate" : `Month ${cash.paybackMonth}`;
|
||||
|
||||
const logos = [brand.logo, a.customerLogo].filter(Boolean).map((l) => logoRun(l!, 170, 48)).filter(Boolean) as ImageRun[];
|
||||
|
||||
const kpis: string[][] = [
|
||||
["Current annual spend assessed", money(r.addressableSpend)],
|
||||
["Projected annual savings", money(r.totalAnnualSavings)],
|
||||
[`${a.horizonYears}-year net benefit`, money(r.horizonNet)],
|
||||
["One-time migration investment", money(r.totalMigrationCost)],
|
||||
["Blended payback", monthsLabel(r.blendedPaybackMonths)],
|
||||
["Tools consolidated", String(r.toolsRetired)],
|
||||
["Vendors retired", String(r.vendorsRetired)],
|
||||
["Spend reduction", `${r.savingsPct.toFixed(0)}%`],
|
||||
["Current annual cost of ownership", money(r.addressableTco)],
|
||||
["Net annual benefit at full run-rate", money(cash.runRateNet)],
|
||||
[`${a.horizonYears}-year net benefit (plan-adjusted)`, money(cash.horizonNet)],
|
||||
[`Net present value at ${a.finance?.discountRate ?? 10}%`, money(cash.npv)],
|
||||
["Payback", payback],
|
||||
...(cash.irr === null ? [] : [["Internal rate of return", `${Math.round(cash.irr)}%`]]),
|
||||
["Tool footprint", `${r.toolsRetired} → ${r.toolsAfter} tools`],
|
||||
["Vendors", `${r.vendorsRetired} → ${r.vendorsAfter}`],
|
||||
];
|
||||
|
||||
const children = [
|
||||
new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: "SOFTWARE PORTFOLIO RATIONALIZATION", bold: true, size: 18, color: ACCENT })] }),
|
||||
...(logos.length ? [new Paragraph({ spacing: { after: 240 }, children: logos.flatMap((l, i) => (i ? [new TextRun({ text: " " }), l] : [l])) })] : []),
|
||||
new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: `SOFTWARE PORTFOLIO RATIONALIZATION${brand.companyName ? ` · ${brand.companyName.toUpperCase()}` : ""}`, bold: true, size: 18, color: ACCENT })] }),
|
||||
new Paragraph({ spacing: { after: 80 }, children: [new TextRun({ text: a.customerName || "Customer", bold: true, size: 48, color: INK })] }),
|
||||
p(
|
||||
[a.industry, a.preparedFor && `Prepared for ${a.preparedFor}`, a.preparedBy && `Prepared by ${a.preparedBy}`, a.opportunityRef]
|
||||
.filter(Boolean)
|
||||
.join(" • "),
|
||||
),
|
||||
p(new Date(a.updatedAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })),
|
||||
p(`${new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })} · ${e.scenario.name} scenario`),
|
||||
|
||||
h1("Executive summary"),
|
||||
...(a.executiveSummary.trim()
|
||||
? a.executiveSummary.split(/\n{2,}/).map((s) => p(s.trim(), INK))
|
||||
: [
|
||||
p(
|
||||
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ${money(r.totalAnnualSavings)} of annual run-rate cost — a ${r.savingsPct.toFixed(0)}% reduction against ${money(r.addressableSpend)} of assessed spend. Net of ${money(r.totalMigrationCost)} in migration investment, the ${a.horizonYears}-year net benefit is ${money(r.horizonNet)}, with blended payback at ${monthsLabel(r.blendedPaybackMonths)}.`,
|
||||
INK,
|
||||
),
|
||||
]),
|
||||
...summary.split(/\n{2,}/).map((s) => p(s.trim(), INK)),
|
||||
|
||||
h1("The financial case"),
|
||||
table(["Measure", "Value"], kpis, ["left", "right"]),
|
||||
|
||||
h1("Rationalization roadmap"),
|
||||
h2("Year-by-year cash flow"),
|
||||
table(
|
||||
["", "Recurring change", "One-time", "Net", "Cumulative"],
|
||||
cash.years.map((y) => [`Year ${y.year} (${L((y.year - 1) * 12)} – ${L(y.year * 12 - 1)})`, money(y.removed - y.subscription), y.oneTime ? `−${money(y.oneTime)}` : "—", money(y.net), money(y.cumulative)]),
|
||||
["left", "right", "right", "right", "right"],
|
||||
),
|
||||
];
|
||||
|
||||
if (a.showScenarioComparison) {
|
||||
children.push(h2("Scenarios considered"));
|
||||
children.push(table(
|
||||
["Scenario", "Tools", "Net / year", `${a.horizonYears}-year net`, "NPV", "Payback"],
|
||||
allScenarios(a).map((sc) => {
|
||||
const x = evaluate(a, catalog, sc);
|
||||
return [`${sc.name}${sc.id === e.scenario.id ? " (presented)" : ""}`, String(x.r.toolsRetired), money(x.cash.runRateNet), money(x.cash.horizonNet), money(x.cash.npv), x.cash.paybackMonth ? `Month ${x.cash.paybackMonth}` : "—"];
|
||||
}),
|
||||
["left", "right", "right", "right", "right", "right"],
|
||||
));
|
||||
}
|
||||
|
||||
children.push(h1("Where the customer pays more than once"));
|
||||
children.push(table(
|
||||
["Capability", "Tools", "Vendors", "Current cost", "Net annual benefit"],
|
||||
[...r.byDomain].sort((x, y) => y.count - x.count || y.spend - x.spend).map((d) => [d.name, String(d.count), String(d.vendors), money(d.spend), money(d.savings)]),
|
||||
["left", "right", "right", "right", "right"],
|
||||
));
|
||||
|
||||
children.push(h1("Value by business unit"));
|
||||
children.push(table(
|
||||
["Business unit", "Tools displaced", "Our products", "Net annual benefit", `${a.horizonYears}-year net`],
|
||||
r.byBu.map((b) => [b.name, String(b.count), b.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", "), money(b.savings), money(b.horizonNet)]),
|
||||
["left", "right", "left", "right", "right"],
|
||||
));
|
||||
|
||||
children.push(h1("Rationalization roadmap"));
|
||||
for (const w of [1, 2, 3] as const) {
|
||||
const items = r.byWave[w];
|
||||
if (!items.length) continue;
|
||||
@@ -109,13 +158,13 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
children.push(p(WAVE_BLURB[w]));
|
||||
children.push(
|
||||
table(
|
||||
["Incumbent tool", "Replaced by", "Annual spend", "Annual saving", "Payback"],
|
||||
["Incumbent tool", "Replaced by", "Current cost", "Net annual benefit", "Payback"],
|
||||
items.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return [
|
||||
`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—",
|
||||
c.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", ") || "—",
|
||||
money(c.annualSpend),
|
||||
money(m.tco),
|
||||
money(m.annualSavings),
|
||||
monthsLabel(m.paybackMonths),
|
||||
];
|
||||
@@ -125,14 +174,13 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
);
|
||||
}
|
||||
|
||||
children.push(h1("Consolidation detail by capability"));
|
||||
children.push(
|
||||
table(
|
||||
["Capability domain", "Tools", "Current spend", "Annual saving"],
|
||||
r.byDomain.map((d) => [d.name, String(d.count), money(d.spend), money(d.savings)]),
|
||||
["left", "right", "right", "right"],
|
||||
),
|
||||
);
|
||||
children.push(h1("Implementation plan"));
|
||||
children.push(p(`${plan.items.length} projects from ${L(0)} to ${L(Math.max(0, plan.completion - 1))}; full run-rate savings from ${L(plan.fullRunRate)}.`));
|
||||
children.push(table(
|
||||
["Project", "Replacement", "Start", "Cutover", "Contract ends"],
|
||||
plan.items.map((i) => [`${i.label}${i.bridge ? " (bridge extension)" : ""}`, i.products, L(i.start), L(i.end - 1), i.contractEnd === null ? "—" : L(i.contractEnd)]),
|
||||
["left", "left", "right", "right", "right"],
|
||||
));
|
||||
|
||||
children.push(h1("Assessment detail"));
|
||||
children.push(
|
||||
@@ -163,21 +211,26 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
}
|
||||
}
|
||||
|
||||
children.push(h1("Next steps"));
|
||||
nextSteps.forEach((s, i) => children.push(p(`${i + 1}. ${s}`, INK)));
|
||||
|
||||
children.push(h1("Method"));
|
||||
children.push(
|
||||
p(
|
||||
`Each candidate is scored on functional parity, ease of switching and time to ROI, weighted ${a.weights.capability}/${a.weights.switchEase}/${a.weights.timeToRoi} with a ${a.weights.spend} weighting on spend at stake. Scores of 65 and above are sequenced into Wave 1, 45–64 into Wave 2, and the remainder into Wave 3. Savings are modelled as a stated percentage of current annual spend per tool and held flat across the ${a.horizonYears}-year horizon; migration cost is a one-time charge in year one. Figures are estimates for planning and are not a quotation.`,
|
||||
`Current cost is each tool's total cost of ownership: licence, supporting infrastructure, administration effort and other run costs. Net benefit is the cost removed once a tool is retired, less the cost of the replacement. Savings begin when each migration completes, or when the incumbent contract ends if later; one-time costs fall when each migration starts. NPV discounts monthly cash flows at ${a.finance?.discountRate ?? 10}% a year over the ${a.horizonYears}-year horizon. Candidates are scored on functional parity, ease of switching and time to ROI, weighted ${a.weights.capability}/${a.weights.switchEase}/${a.weights.timeToRoi} with a ${a.weights.spend} weighting on cost at stake. Figures are estimates for planning and are not a quotation.`,
|
||||
),
|
||||
);
|
||||
|
||||
const doc = new Document({
|
||||
creator: a.preparedBy || "Converge",
|
||||
creator: a.preparedBy || brand.companyName || "Converge",
|
||||
title: `${a.customerName} — Software Portfolio Rationalization`,
|
||||
styles: { default: { document: { run: { font: "Aptos", size: 21, color: MUTED } } } },
|
||||
styles: { default: { document: { run: { font: "Arial", size: 21, color: MUTED } } } },
|
||||
sections: [{ properties: { page: { margin: { top: 1000, bottom: 1000, left: 1000, right: 1000 } } }, children }],
|
||||
});
|
||||
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
// What the customer receives is frozen, so it can always be reproduced.
|
||||
await createSnapshot(stored, `Word export — ${new Date().toLocaleDateString("en-US")}`, "word").catch(() => {});
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
|
||||
25
src/app/api/assessments/[id]/snapshots/[sid]/route.ts
Normal file
25
src/app/api/assessments/[id]/snapshots/[sid]/route.ts
Normal 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);
|
||||
}
|
||||
20
src/app/api/assessments/[id]/snapshots/route.ts
Normal file
20
src/app/api/assessments/[id]/snapshots/route.ts
Normal 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));
|
||||
}
|
||||
10
src/app/api/portfolio/route.ts
Normal file
10
src/app/api/portfolio/route.ts
Normal 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));
|
||||
}
|
||||
22
src/app/api/settings/route.ts
Normal file
22
src/app/api/settings/route.ts
Normal 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) || "",
|
||||
}));
|
||||
}
|
||||
@@ -2,14 +2,23 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Assessment, Candidate, Catalog, IntelImportResult, IntelProposal, KnowledgeBase, Survey, Weights } from "@/lib/types";
|
||||
import { DEFAULT_WEIGHTS, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import { dateLabel, money, monthsLabel } from "@/lib/format";
|
||||
import { ChartFrame, Empty, Quadrant, StatTile } from "@/components/charts";
|
||||
import type { Assessment, Candidate, Catalog, DealStatus, IntelImportResult, IntelProposal, KnowledgeBase, PlanOverride, Survey, Weights } from "@/lib/types";
|
||||
import { DEFAULT_WEIGHTS, TCO_FALLBACK, WAVE_LABEL } from "@/lib/scoring";
|
||||
import { evaluate } from "@/lib/finance";
|
||||
import { entitlementFlags } from "@/lib/shelfware";
|
||||
import { findEntry, candidateLabel } from "@/lib/kb";
|
||||
import { STATUS_LABEL } from "@/lib/portfolio";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { ChartFrame, Empty, Quadrant } from "@/components/charts";
|
||||
import CandidateRow from "@/components/CandidateRow";
|
||||
import IntelImport from "@/components/IntelImport";
|
||||
import AccountIntelStrip from "@/components/AccountIntelStrip";
|
||||
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
|
||||
import { BuBreakdown, ImplementationTimeline } from "@/components/Analysis";
|
||||
import { FinancePanel, HeadlineTiles, ScenarioPanel } from "@/components/Finance";
|
||||
import { OverlapMap } from "@/components/Overlap";
|
||||
import { ShelfwarePanel } from "@/components/Shelfware";
|
||||
import SnapshotPanel from "@/components/Snapshots";
|
||||
import { LogoInput } from "@/components/LogoInput";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import SurveyPanel from "@/components/SurveyPanel";
|
||||
import SurveyReview, { kbEntryFor, toolVendorProduct } from "@/components/SurveyReview";
|
||||
@@ -36,6 +45,7 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
const [surveys, setSurveys] = useState<Survey[]>([]);
|
||||
const [showSurveys, setShowSurveys] = useState(false);
|
||||
const [reviewing, setReviewing] = useState<Survey | null>(null);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
@@ -99,7 +109,37 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
});
|
||||
}, [save]);
|
||||
|
||||
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
|
||||
// The workbench shows the assessment as it stands (the Expected scenario).
|
||||
const ev = useMemo(() => (a && catalog ? evaluate(a, catalog) : null), [a, catalog]);
|
||||
const r = ev?.r ?? null;
|
||||
|
||||
// Keep derived line data current: TCO baselines from the knowledge base for
|
||||
// lines that predate them, and shelfware entitlements from the dossier.
|
||||
useEffect(() => {
|
||||
if (!a || !catalog) return;
|
||||
const flags = entitlementFlags(a, catalog);
|
||||
const needsTco = a.candidates.some((c) => !c.tcoDefaults && candidateLabel(c));
|
||||
const flagDrift = a.candidates.some((c) => (flags.get(c.id) ?? undefined) !== (c.alreadyEntitled ?? undefined));
|
||||
if (!flagDrift && !(needsTco && kb)) return;
|
||||
update((d) => {
|
||||
for (const c of d.candidates) {
|
||||
const f = flags.get(c.id);
|
||||
if (f) c.alreadyEntitled = f; else delete c.alreadyEntitled;
|
||||
if (!c.tcoDefaults && kb && candidateLabel(c)) c.tcoDefaults = { ...(findEntry(kb, candidateLabel(c))?.tco ?? TCO_FALLBACK) };
|
||||
}
|
||||
});
|
||||
}, [a, catalog, kb, update]);
|
||||
|
||||
const setPlan = useCallback((id: string, patch: PlanOverride | null) => update((d) => {
|
||||
const c = d.candidates.find((x) => x.id === id);
|
||||
if (!c) return;
|
||||
if (!patch) { delete c.plan; return; }
|
||||
const clean: PlanOverride = {};
|
||||
if (patch.start) clean.start = patch.start;
|
||||
if (patch.duration) clean.duration = patch.duration;
|
||||
if (patch.after?.length) clean.after = patch.after;
|
||||
if (Object.keys(clean).length) c.plan = clean; else delete c.plan;
|
||||
}), [update]);
|
||||
|
||||
/** Accepted proposals become real candidates; the dossier itself stays internal. */
|
||||
function acceptIntel(result: IntelImportResult, chosen: IntelProposal[]) {
|
||||
@@ -151,6 +191,7 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
}
|
||||
const e = kbEntryFor(kb, t);
|
||||
const c: Candidate = {
|
||||
tcoDefaults: { ...(e?.tco ?? TCO_FALLBACK) },
|
||||
...newCandidate(t.domain),
|
||||
incumbentVendor: vendor,
|
||||
incumbentProduct: product,
|
||||
@@ -192,7 +233,7 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!a || !catalog || !r) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>;
|
||||
if (!a || !catalog || !r || !ev) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>;
|
||||
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? "Unassigned";
|
||||
|
||||
@@ -247,11 +288,17 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<span className="tabnum min-w-[54px] text-[12px]" style={{ color: status === "error" ? "var(--critical)" : "var(--text-muted)" }}>{statusText}</span>
|
||||
<select className="field py-[6px]" style={{ width: "auto" }} aria-label="Deal status" value={a.status ?? "open"}
|
||||
onChange={(e) => update((d) => { d.status = e.target.value as DealStatus; })}>
|
||||
{(Object.keys(STATUS_LABEL) as DealStatus[]).map((k) => <option key={k} value={k}>{STATUS_LABEL[k]}</option>)}
|
||||
</select>
|
||||
<button className="btn" onClick={() => setShowVersions(true)}>Versions</button>
|
||||
<button className="btn" onClick={() => setShowSurveys(true)}>Questionnaires{surveys.length ? ` · ${surveys.length}` : ""}</button>
|
||||
<button className="btn" onClick={() => setShowImport(true)}>Import dossier</button>
|
||||
<button className="btn" onClick={exportCsv}>CSV</button>
|
||||
<button className="btn" onClick={exportJson}>JSON</button>
|
||||
<a className="btn" href={`/api/assessments/${a.id}/docx`}>Word</a>
|
||||
<a className="btn" href={`/api/assessments/${a.id}/deck`}>Deck</a>
|
||||
<Link className="btn btn-primary" href={`/assessments/${a.id}/report`}>Customer report →</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +314,10 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
<input id={`m-${k}`} className="field" value={a[k]} onChange={(e) => update((d) => { d[k] = e.target.value; })} />
|
||||
</div>
|
||||
))}
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<LogoInput label="Customer logo (PNG or JPEG — shown on the report, Word file and deck)" value={a.customerLogo ?? ""}
|
||||
onChange={(v) => update((d) => { if (v) d.customerLogo = v; else delete d.customerLogo; })} />
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<label className="label" htmlFor="m-summary">Executive summary (leave blank to auto-generate in the report)</label>
|
||||
<textarea id="m-summary" className="field" rows={3} value={a.executiveSummary}
|
||||
@@ -305,18 +356,17 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
onAccept={(acc, dis) => acceptSurvey(reviewing, acc, dis)} />
|
||||
)}
|
||||
|
||||
{showVersions && (
|
||||
<SnapshotPanel a={a} catalog={catalog} onClose={() => setShowVersions(false)}
|
||||
onRestored={(next) => { if (timer.current) clearTimeout(timer.current); setA(next); setStatus("saved"); }} />
|
||||
)}
|
||||
|
||||
{showImport && (
|
||||
<IntelImport catalog={catalog} onClose={() => setShowImport(false)} onAccept={acceptIntel} />
|
||||
)}
|
||||
|
||||
{/* ---- KPIs ---- */}
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatTile label="Annual spend assessed" value={money(r.addressableSpend)} sub={`${r.toolsRetired} tools · ${r.vendorsRetired} vendors`} />
|
||||
<StatTile label="Projected annual saving" value={money(r.totalAnnualSavings)} tone="good" sub={`${r.savingsPct.toFixed(0)}% of assessed spend`} />
|
||||
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(r.horizonNet)} tone="accent" sub={`After ${money(r.totalMigrationCost)} migration investment`} />
|
||||
<StatTile label="Blended payback" value={monthsLabel(r.blendedPaybackMonths)} sub={`Average score ${r.avgScore.toFixed(0)}/100`} />
|
||||
<ToolFootprintTile r={r} />
|
||||
</div>
|
||||
<HeadlineTiles e={ev} internal />
|
||||
|
||||
<div className="mb-5 grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<WeightsPanel a={a} update={update} />
|
||||
@@ -346,11 +396,19 @@ export default function Workbench({ params }: { params: Promise<{ id: string }>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-5"><FinancePanel a={a} e={ev} update={update} /></div>
|
||||
<div className="mb-5"><ScenarioPanel a={a} catalog={catalog} update={update} /></div>
|
||||
|
||||
<div className="mb-5 grid gap-4 xl:grid-cols-2">
|
||||
<OverlapMap r={r} catalog={catalog} />
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</div>
|
||||
|
||||
<ImplementationTimeline a={a} r={r} catalog={catalog} onParallel={(n) => update((d) => { d.planParallel = n; })} />
|
||||
<ShelfwarePanel a={a} catalog={catalog} />
|
||||
|
||||
<ImplementationTimeline e={ev} catalog={catalog} onPlan={setPlan}
|
||||
onParallel={(n) => update((d) => { d.planParallel = n; })}
|
||||
onResetAll={() => update((d) => { for (const c of d.candidates) delete c.plan; })} />
|
||||
|
||||
{/* ---- candidates ---- */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import type { Assessment, Branding, Catalog } from "@/lib/types";
|
||||
import { WAVE_BLURB, WAVE_LABEL } from "@/lib/scoring";
|
||||
import { evaluate, findScenario } from "@/lib/finance";
|
||||
import { dateLabel, money, monthsLabel } from "@/lib/format";
|
||||
import { ChartFrame, Empty, HBar, PaybackCurve, Quadrant, StatTile } from "@/components/charts";
|
||||
import { ChartFrame, Empty, Quadrant } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
|
||||
import { errorMessage, getJson } from "@/lib/client";
|
||||
import { BuBreakdown, ImplementationTimeline } from "@/components/Analysis";
|
||||
import { CashFlowTable, CumulativeChart, HeadlineTiles, ScenarioComparison } from "@/components/Finance";
|
||||
import { OverlapMap } from "@/components/Overlap";
|
||||
import { summaryText } from "@/lib/deliverable";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
|
||||
|
||||
@@ -16,6 +20,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
const { id } = use(params);
|
||||
const [a, setA] = useState<Assessment | null>(null);
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [brand, setBrand] = useState<Branding | null>(null);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
@@ -24,43 +29,61 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
setLoadError("");
|
||||
|
||||
// Deliberately the redacted projection, not the full record.
|
||||
Promise.all([getJson<Assessment>(`/api/assessments/${id}/report`), getJson<Catalog>("/api/catalog")])
|
||||
.then(([as, cat]) => { if (!cancelled) { setA(as); setCatalog(cat); } })
|
||||
Promise.all([
|
||||
getJson<Assessment>(`/api/assessments/${id}/report`),
|
||||
getJson<Catalog>("/api/catalog"),
|
||||
getJson<Branding>("/api/settings").catch(() => null),
|
||||
])
|
||||
.then(([as, cat, b]) => { if (!cancelled) { setA(as); setCatalog(cat); setBrand(b); } })
|
||||
.catch((err) => { if (!cancelled) setLoadError(errorMessage(err)); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, attempt]);
|
||||
|
||||
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
|
||||
// The report presents the scenario the seller chose.
|
||||
const e = useMemo(() => (a && catalog ? evaluate(a, catalog, findScenario(a, a.presentedScenario)) : null), [a, catalog]);
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
|
||||
if (!a || !catalog || !r) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading report…</div>;
|
||||
if (!a || !catalog || !e) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading report…</div>;
|
||||
|
||||
const { r, cash, plan } = e;
|
||||
const prodName = (pid: string) => catalog.products.find((p) => p.id === pid)?.name ?? pid;
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? "Unassigned";
|
||||
const accent = brand?.accent || undefined;
|
||||
|
||||
const autoSummary =
|
||||
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ` +
|
||||
`${money(r.totalAnnualSavings)} of annual run-rate cost — a ${r.savingsPct.toFixed(0)}% reduction against ` +
|
||||
`${money(r.addressableSpend)} of assessed spend, and reduces the estate from ${r.toolsRetired} tools to ${r.toolsAfter}. Net of ${money(r.totalMigrationCost)} in one-time migration ` +
|
||||
`investment, the ${a.horizonYears}-year net benefit is ${money(r.horizonNet)}, with blended payback at ` +
|
||||
`${monthsLabel(r.blendedPaybackMonths)}.`;
|
||||
|
||||
async function print() {
|
||||
// What the customer is about to receive is frozen first, so it can always be reproduced.
|
||||
await sendJson(`/api/assessments/${a!.id}/snapshots`, "POST", { label: `Printed report — ${new Date().toLocaleDateString()}`, trigger: "print" }).catch(() => {});
|
||||
window.print();
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mx-auto max-w-[1180px]">
|
||||
{/* ---- toolbar (screen only) ---- */}
|
||||
<div className="no-print mb-6 flex flex-wrap items-center gap-2">
|
||||
<Link href={`/assessments/${a.id}`} className="text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">← Back to workbench</Link>
|
||||
<span className="text-[12px] text-[var(--text-muted)]">· Presenting the <strong>{e.scenario.name}</strong> scenario</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<a className="btn" href={`/api/assessments/${a.id}/docx`}>Download Word</a>
|
||||
<button className="btn btn-primary" onClick={() => window.print()}>Print / Save as PDF</button>
|
||||
<a className="btn" href={`/api/assessments/${a.id}/deck`}>Download deck</a>
|
||||
<button className="btn btn-primary" onClick={print}>Print / Save as PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- cover ---- */}
|
||||
<header className="avoid-break mb-8 border-b pb-7">
|
||||
<div className="text-[11.5px] font-semibold uppercase tracking-[.1em]" style={{ color: "var(--accent)" }}>
|
||||
Software portfolio rationalization
|
||||
{(brand?.logo || a.customerLogo) && (
|
||||
<div className="mb-5 flex items-center gap-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{brand?.logo && <img src={brand.logo} alt={brand.companyName || "Our logo"} className="h-9 max-w-[180px] object-contain" />}
|
||||
{brand?.logo && a.customerLogo && <span className="h-7 w-px bg-[var(--border-strong)]" />}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{a.customerLogo && <img src={a.customerLogo} alt={a.customerName} className="h-9 max-w-[180px] object-contain" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[11.5px] font-semibold uppercase tracking-[.1em]" style={{ color: accent ?? "var(--accent)" }}>
|
||||
Software portfolio rationalization{brand?.companyName ? ` · ${brand.companyName}` : ""}
|
||||
</div>
|
||||
<h1 className="mt-2 text-[40px] font-semibold leading-[1.1] tracking-tight">{a.customerName}</h1>
|
||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-[var(--text-secondary)]">
|
||||
@@ -80,46 +103,39 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<section className="avoid-break mb-7">
|
||||
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Executive summary</h2>
|
||||
<div className="max-w-[72ch] space-y-3 text-[14.5px] leading-[1.65] text-[var(--text-secondary)]">
|
||||
{(a.executiveSummary.trim() || autoSummary).split(/\n{2,}/).map((p, i) => <p key={i}>{p}</p>)}
|
||||
{summaryText(e).split(/\n{2,}/).map((p, i) => <p key={i}>{p}</p>)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- headline numbers ---- */}
|
||||
<section className="avoid-break mb-7 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatTile label="Annual spend assessed" value={money(r.addressableSpend)} sub={`Across ${r.toolsRetired} tools`} />
|
||||
<StatTile label="Projected annual saving" value={money(r.totalAnnualSavings)} tone="good" sub={`${r.savingsPct.toFixed(0)}% run-rate reduction`} />
|
||||
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(r.horizonNet)} tone="accent" sub={`After ${money(r.totalMigrationCost)} migration investment`} />
|
||||
<StatTile label="Blended payback" value={monthsLabel(r.blendedPaybackMonths)} sub={`${r.vendorsRetired} vendor relationships retired`} />
|
||||
<ToolFootprintTile r={r} />
|
||||
</section>
|
||||
<HeadlineTiles e={e} />
|
||||
|
||||
{/* ---- value over time ---- */}
|
||||
<section className="mb-7 grid gap-4 lg:grid-cols-2">
|
||||
<ChartFrame
|
||||
title="Cumulative net benefit"
|
||||
subtitle={`Savings held flat across ${a.horizonYears} years, net of one-time migration cost.`}
|
||||
table={{
|
||||
headers: ["Point", "Net benefit"],
|
||||
rows: Array.from({ length: a.horizonYears + 1 }, (_, i) => [
|
||||
i === 0 ? "Start" : `Year ${i}`, money(r.totalAnnualSavings * i - r.totalMigrationCost),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
<PaybackCurve annualSavings={r.totalAnnualSavings} migrationCost={r.totalMigrationCost} years={a.horizonYears} />
|
||||
<ChartFrame title="Cumulative net benefit"
|
||||
subtitle="Month by month as each tool is retired, net of one-time costs."
|
||||
table={{ headers: ["Year", "Net", "Cumulative"], rows: cash.years.map((y) => [`Year ${y.year}`, money(y.net), money(y.cumulative)]) }}>
|
||||
<CumulativeChart e={e} />
|
||||
</ChartFrame>
|
||||
<section className="card avoid-break p-5">
|
||||
<h3 className="mb-4 text-[14px] font-semibold tracking-tight">Year-by-year cash flow</h3>
|
||||
<CashFlowTable e={e} showSubscription={false} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<ChartFrame
|
||||
title="Annual saving by capability domain"
|
||||
subtitle="Where the consolidation value concentrates."
|
||||
table={{
|
||||
headers: ["Domain", "Tools", "Current spend", "Annual saving"],
|
||||
rows: r.byDomain.map((d) => [d.name, d.count, money(d.spend), money(d.savings)]),
|
||||
}}
|
||||
>
|
||||
<HBar data={r.byDomain.slice(0, 9).map((d) => ({
|
||||
label: d.name, value: d.savings, note: `${d.count} tool${d.count > 1 ? "s" : ""} · ${money(d.spend)} current spend`,
|
||||
}))} />
|
||||
</ChartFrame>
|
||||
{a.showScenarioComparison && (
|
||||
<section className="card avoid-break mb-7 p-5">
|
||||
<h2 className="mb-1 text-[15px] font-semibold tracking-tight">Scenarios considered</h2>
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
Conservative and aggressive cases vary the value realized and the pace of migration.
|
||||
</p>
|
||||
<ScenarioComparison a={a} catalog={catalog} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- where the overlap is ---- */}
|
||||
<section className="mb-7 grid gap-4 lg:grid-cols-2">
|
||||
<OverlapMap r={r} catalog={catalog} />
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</section>
|
||||
|
||||
{/* ---- roadmap ---- */}
|
||||
@@ -127,7 +143,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<h2 className="mb-1 text-[19px] font-semibold tracking-tight">Rationalization roadmap</h2>
|
||||
<p className="mb-4 max-w-[72ch] text-[13.5px] text-[var(--text-secondary)]">
|
||||
Candidates are sequenced by a weighted score of capability parity, switching effort, speed to payback and
|
||||
spend at stake. Wave 1 carries the least risk and the fastest return.
|
||||
cost at stake. Wave 1 carries the least risk and the fastest return.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
@@ -141,7 +157,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<p className="mt-1 text-[12px] leading-snug text-[var(--text-muted)]">{WAVE_BLURB[w]}</p>
|
||||
<div className="mt-2.5 flex items-baseline gap-2">
|
||||
<span className="tabnum text-[19px] font-semibold" style={{ color: "var(--success-text)" }}>{money(savings, true)}</span>
|
||||
<span className="text-[12px] text-[var(--text-muted)]">per year · {items.length} tool{items.length === 1 ? "" : "s"}</span>
|
||||
<span className="text-[12px] text-[var(--text-muted)]">net per year · {items.length} tool{items.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
@@ -157,7 +173,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
→ {c.productIds.map(prodName).join(", ") || "Not yet mapped"}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 tabnum text-[11.5px] text-[var(--text-muted)]">
|
||||
<span>{money(m.annualSavings)}/yr saved</span>
|
||||
<span>{money(m.annualSavings)}/yr net</span>
|
||||
<span>{monthsLabel(m.paybackMonths)} payback</span>
|
||||
{c.renewalDate && <span>Renews {dateLabel(c.renewalDate)}</span>}
|
||||
</div>
|
||||
@@ -173,16 +189,16 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
</section>
|
||||
|
||||
{/* ---- implementation plan ---- */}
|
||||
<ImplementationTimeline a={a} r={r} catalog={catalog} />
|
||||
<ImplementationTimeline e={e} catalog={catalog} />
|
||||
|
||||
{/* ---- quadrant ---- */}
|
||||
<section className="mb-7">
|
||||
<ChartFrame
|
||||
title="Replacement readiness"
|
||||
subtitle="Capability parity against ease of switching, sized by annual spend at stake."
|
||||
subtitle="Capability parity against ease of switching, sized by current cost of ownership."
|
||||
table={{
|
||||
headers: ["Incumbent", "Parity", "Switch ease", "Spend at stake"],
|
||||
rows: r.included.map((c) => [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(c.annualSpend)]),
|
||||
headers: ["Incumbent", "Parity", "Switch ease", "Current cost"],
|
||||
rows: r.included.map((c) => [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(r.metrics.get(c.id)!.tco)]),
|
||||
}}
|
||||
>
|
||||
<Quadrant
|
||||
@@ -190,7 +206,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
points={r.included.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return {
|
||||
id: c.id, x: c.switchEase, y: c.capability, size: Math.max(c.annualSpend, 1),
|
||||
id: c.id, x: c.switchEase, y: c.capability, size: Math.max(m.tco, 1),
|
||||
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
|
||||
sublabel: c.productIds.map(prodName).join(", ") || "Not yet mapped",
|
||||
colorIndex: m.wave - 1, groupLabel: WAVE_LABEL[m.wave],
|
||||
@@ -198,12 +214,6 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
})}
|
||||
/>
|
||||
</ChartFrame>
|
||||
|
||||
</section>
|
||||
|
||||
{/* ---- value by business unit ---- */}
|
||||
<section className="mb-7">
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</section>
|
||||
|
||||
{/* ---- detail table ---- */}
|
||||
@@ -213,7 +223,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--surface-2)]">
|
||||
{["Incumbent tool", "Capability domain", "Proposed platform", "Annual spend", "Annual saving", "Parity", "Switch", "Payback", "Wave"].map((h, i) => (
|
||||
{["Incumbent tool", "Capability domain", "Proposed platform", "Current cost", "Net annual benefit", "Parity", "Switch", "Payback", "Wave"].map((h, i) => (
|
||||
<th key={h} className={`px-3 py-2.5 font-semibold ${i >= 3 ? "text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -226,7 +236,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<td className="px-3 py-2.5 font-medium">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—"}</td>
|
||||
<td className="px-3 py-2.5 text-[var(--text-secondary)]">{domainName(c.domain)}</td>
|
||||
<td className="px-3 py-2.5 text-[var(--text-secondary)]">{c.productIds.map(prodName).join(", ") || "—"}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(c.annualSpend)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(m.tco)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{c.capability}/5</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{c.switchEase}/5</td>
|
||||
@@ -241,7 +251,7 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<tfoot>
|
||||
<tr className="border-t-2 bg-[var(--surface-2)] font-semibold">
|
||||
<td className="px-3 py-2.5" colSpan={3}>Total — {r.toolsRetired} tools, {r.vendorsRetired} vendors</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(r.addressableSpend)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{money(r.addressableTco)}</td>
|
||||
<td className="px-3 py-2.5 text-right tabnum" style={{ color: "var(--success-text)" }}>{money(r.totalAnnualSavings)}</td>
|
||||
<td className="px-3 py-2.5" colSpan={4} />
|
||||
</tr>
|
||||
@@ -269,12 +279,14 @@ export default function Report({ params }: { params: Promise<{ id: string }> })
|
||||
<section className="avoid-break border-t pt-5">
|
||||
<h2 className="mb-2 text-[14px] font-semibold tracking-tight">Method and assumptions</h2>
|
||||
<p className="max-w-[80ch] text-[12px] leading-relaxed text-[var(--text-muted)]">
|
||||
Each candidate is scored on capability parity, ease of switching and time to ROI, weighted{" "}
|
||||
{a.weights.capability}/{a.weights.switchEase}/{a.weights.timeToRoi} with a {a.weights.spend} weighting on
|
||||
spend at stake. Scores of 65 and above are sequenced into Wave 1, 45–64 into Wave 2, and the remainder into
|
||||
Wave 3. Savings are modelled as a stated percentage of current annual spend per tool and held flat across
|
||||
the {a.horizonYears}-year horizon; migration cost is treated as a one-time charge in year one. Spend figures
|
||||
are supplied by or estimated with the customer. These are planning estimates, not a quotation.
|
||||
Current cost is each tool’s total cost of ownership: licence, supporting infrastructure, administration
|
||||
effort and other run costs. Net benefit is the cost removed once a tool is retired, less the cost of the
|
||||
replacement. Savings begin when each migration completes, or when the incumbent contract ends if later, following
|
||||
the implementation plan; one-time costs fall when each migration starts. NPV discounts monthly cash flows at{" "}
|
||||
{a.finance?.discountRate ?? 10}% a year over the {a.horizonYears}-year horizon. Candidates are scored on capability
|
||||
parity, ease of switching and time to ROI, weighted {a.weights.capability}/{a.weights.switchEase}/{a.weights.timeToRoi}{" "}
|
||||
with a {a.weights.spend} weighting on cost at stake; scores of 65 and above form Wave 1, 45–64 Wave 2, the rest
|
||||
Wave 3. Figures are planning estimates, not a quotation.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BusinessUnit, Catalog, Product } from "@/lib/types";
|
||||
import { Empty } from "@/components/charts";
|
||||
import { BRAND_SWATCHES } from "@/lib/brand";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
@@ -140,7 +141,7 @@ export default function CatalogPage() {
|
||||
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
|
||||
{(["all", "on", "off"] as const).map((s) => (
|
||||
<button key={s}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium ${state === s ? "bg-[var(--accent)] text-white" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium ${state === s ? "bg-[var(--accent)] text-[var(--accent-ink)]" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
onClick={() => setState(s)}>
|
||||
{s === "all" ? "All" : s === "on" ? "In scope" : "Disabled"}
|
||||
</button>
|
||||
@@ -324,9 +325,8 @@ function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Cat
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{cat.businessUnits.map((b) => (
|
||||
<div key={b.id} className="flex items-center gap-2 rounded-lg border px-2.5 py-2">
|
||||
<input type="color" className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||
value={b.color} aria-label={`${buLabel(b)} colour`}
|
||||
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.color = e.target.value; })} />
|
||||
<BrandSwatch value={b.color} label={`${buLabel(b)} colour`}
|
||||
onChange={(hex) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.color = hex; })} />
|
||||
<input className="field py-1 text-[12.5px]" value={b.name} placeholder="Name" aria-label="Business unit name"
|
||||
autoFocus={b.id === addedId}
|
||||
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.name = e.target.value; })} />
|
||||
@@ -344,7 +344,7 @@ function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Cat
|
||||
onClick={() => {
|
||||
let id = "bu", n = 2;
|
||||
while (cat.businessUnits.some((b) => b.id === id)) id = `bu-${n++}`;
|
||||
mutate((c) => { c.businessUnits.push({ id, name: "", short: "", color: "#898781" }); });
|
||||
mutate((c) => { c.businessUnits.push({ id, name: "", short: "", color: BRAND_SWATCHES[c.businessUnits.length % BRAND_SWATCHES.length].hex }); });
|
||||
setAddedId(id);
|
||||
}}>
|
||||
+ Add business unit
|
||||
@@ -352,3 +352,23 @@ function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Cat
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Colour choice limited to the brand palette (Brand Guidelines: no off-palette colours). */
|
||||
function BrandSwatch({ value, label, onChange }: { value: string; label: string; onChange: (hex: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button className="h-7 w-7 rounded border" style={{ background: value }} aria-label={label} aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)} />
|
||||
{open && (
|
||||
<div className="card absolute left-0 top-8 z-20 grid w-[148px] grid-cols-4 gap-1.5 p-2" role="listbox" aria-label={label}>
|
||||
{BRAND_SWATCHES.map((s) => (
|
||||
<button key={s.hex} role="option" aria-selected={value.toLowerCase() === s.hex.toLowerCase()} title={`${s.name} ${s.hex}`}
|
||||
className="h-7 w-7 rounded" style={{ background: s.hex, outline: value.toLowerCase() === s.hex.toLowerCase() ? "2px solid var(--text-primary)" : undefined, outlineOffset: 1 }}
|
||||
onClick={() => { onChange(s.hex); setOpen(false); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,89 +2,107 @@
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-1: #fcfcfb; /* cards, chart surface */
|
||||
--surface-2: #f4f3f0; /* inset wells, table stripes */
|
||||
--plane: #f9f9f7; /* page background */
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--grid: #e1e0d9;
|
||||
--axis: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.10);
|
||||
--border-strong: rgba(11, 11, 11, 0.18);
|
||||
/* VMware by Broadcom palette — see src/lib/brand.ts and "Brand Guidelines.pdf". */
|
||||
--surface-1: #ffffff; /* cards, chart surface */
|
||||
--surface-2: #f0f2f6; /* inset wells, table stripes */
|
||||
--plane: #f7f8fa; /* page background */
|
||||
--text-primary: #1b1d36; /* Dark Blue */
|
||||
--text-secondary: #474a66;
|
||||
--text-muted: #6b6e87;
|
||||
--grid: #e3e5ec;
|
||||
--axis: #c5c8d4;
|
||||
--border: rgba(27, 29, 54, 0.11);
|
||||
--border-strong: rgba(27, 29, 54, 0.20);
|
||||
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #4a3aa7;
|
||||
--series-8: #e34948;
|
||||
--series-1: #005c8a; /* Navy */
|
||||
--series-2: #6c4b94; /* Purple */
|
||||
--series-3: #007b8c; /* Aqua */
|
||||
--series-4: #61a60e; /* Green */
|
||||
--series-5: #0098c7; /* Azure */
|
||||
--series-6: #007da3; /* Light Blue */
|
||||
--series-7: #1b1d36; /* Dark Blue */
|
||||
--series-8: #23800a; /* Green (AA) */
|
||||
|
||||
--good: #0ca30c;
|
||||
--warning: #fab219;
|
||||
--serious: #ec835a;
|
||||
--critical: #d03b3b;
|
||||
--success-text: #006300;
|
||||
--good: #61a60e;
|
||||
--warning: #e3a21a; /* warnings only */
|
||||
--serious: #e3a21a;
|
||||
--critical: #b42318; /* errors and destructive actions only */
|
||||
--success-text: #23800a; /* the guide's AA-safe green */
|
||||
|
||||
--accent: #2a78d6;
|
||||
--accent: #005c8a; /* Navy */
|
||||
--accent-ink: #ffffff;
|
||||
--shadow: 0 1px 2px rgba(11,11,11,.05), 0 8px 24px -12px rgba(11,11,11,.18);
|
||||
--shadow: 0 1px 2px rgba(27,29,54,.05), 0 8px 24px -12px rgba(27,29,54,.18);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--plane: #0d0d0d;
|
||||
/* Brand Darkmode navy as the base, with the guide's dark-theme accents. */
|
||||
--surface-1: #232648;
|
||||
--surface-2: #2b2e55;
|
||||
--plane: #1b1d36;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--axis: #383835;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--border-strong: rgba(255, 255, 255, 0.20);
|
||||
--text-secondary: #c9cbe0;
|
||||
--text-muted: #9a9dbb;
|
||||
--grid: #33375f;
|
||||
--axis: #474b78;
|
||||
--border: rgba(255, 255, 255, 0.11);
|
||||
--border-strong: rgba(255, 255, 255, 0.22);
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--series-1: #0088ef; /* Blue (dark theme) */
|
||||
--series-2: #a468ee; /* Purple (dark theme) */
|
||||
--series-3: #01b9c6; /* Aqua (dark theme) */
|
||||
--series-4: #61a60e; /* Green */
|
||||
--series-5: #0098c7; /* Light Blue */
|
||||
--series-6: #7fb3f5; /* Blue tint */
|
||||
--series-7: #c9a5f5; /* Purple tint */
|
||||
--series-8: #8fd66a; /* Green tint */
|
||||
|
||||
--success-text: #0ca30c;
|
||||
--accent: #3987e5;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
--good: #61a60e;
|
||||
--warning: #e3a21a;
|
||||
--serious: #e3a21a;
|
||||
--critical: #f97066;
|
||||
--success-text: #7cc23a;
|
||||
|
||||
--accent: #0088ef;
|
||||
/* White on #0088EF is ~3.4:1, under AA; dark ink on it is ~5.3:1. */
|
||||
--accent-ink: #0b0c1d;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 24px -12px rgba(0,0,0,.6);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--plane: #0d0d0d;
|
||||
/* Brand Darkmode navy as the base, with the guide's dark-theme accents. */
|
||||
--surface-1: #232648;
|
||||
--surface-2: #2b2e55;
|
||||
--plane: #1b1d36;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--grid: #2c2c2a;
|
||||
--axis: #383835;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--border-strong: rgba(255, 255, 255, 0.20);
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
--success-text: #0ca30c;
|
||||
--accent: #3987e5;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
--text-secondary: #c9cbe0;
|
||||
--text-muted: #9a9dbb;
|
||||
--grid: #33375f;
|
||||
--axis: #474b78;
|
||||
--border: rgba(255, 255, 255, 0.11);
|
||||
--border-strong: rgba(255, 255, 255, 0.22);
|
||||
|
||||
--series-1: #0088ef; /* Blue (dark theme) */
|
||||
--series-2: #a468ee; /* Purple (dark theme) */
|
||||
--series-3: #01b9c6; /* Aqua (dark theme) */
|
||||
--series-4: #61a60e; /* Green */
|
||||
--series-5: #0098c7; /* Light Blue */
|
||||
--series-6: #7fb3f5; /* Blue tint */
|
||||
--series-7: #c9a5f5; /* Purple tint */
|
||||
--series-8: #8fd66a; /* Green tint */
|
||||
|
||||
--good: #61a60e;
|
||||
--warning: #e3a21a;
|
||||
--serious: #e3a21a;
|
||||
--critical: #f97066;
|
||||
--success-text: #7cc23a;
|
||||
|
||||
--accent: #0088ef;
|
||||
--accent-ink: #0b0c1d;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 8px 24px -12px rgba(0,0,0,.6);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -161,7 +179,7 @@ select.field { appearance: none; background-image: none; cursor: pointer; }
|
||||
.btn:hover { background: var(--surface-2); border-color: var(--text-muted); }
|
||||
.btn:active { transform: translateY(.5px); }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||
.btn-primary:hover { background: color-mix(in oklab, var(--accent) 86%, #000); border-color: transparent; }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-secondary); }
|
||||
.btn-ghost:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<!-- Favicon: the nav logo simplified for 16px — no trend line, heavier bars. -->
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2a78d6"/>
|
||||
<stop offset="1" stop-color="#1baf7a"/>
|
||||
<stop stop-color="#005C8A"/>
|
||||
<stop offset="1" stop-color="#0098C7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#g)"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 689 B After Width: | Height: | Size: 689 B |
@@ -4,14 +4,15 @@ import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
|
||||
import { CAPABILITY_LABEL, SWITCH_LABEL, money } from "@/lib/format";
|
||||
import { newEntry } from "@/lib/kb";
|
||||
import { confidenceOf, findDuplicates, mergeEntries, newEntry, pairKey } from "@/lib/kb";
|
||||
import { TCO_FALLBACK } from "@/lib/scoring";
|
||||
import { Empty } from "@/components/charts";
|
||||
import { ConfidenceChip } from "@/components/IncumbentPicker";
|
||||
import ProductPicker from "@/components/ProductPicker";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
type Filter = "all" | "learned" | "seeded" | "unmapped";
|
||||
type Filter = "all" | "learned" | "seeded" | "unmapped" | "review";
|
||||
|
||||
export default function KbPage() {
|
||||
const [kb, setKb] = useState<KnowledgeBase | null>(null);
|
||||
@@ -25,6 +26,7 @@ export default function KbPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
const [showDups, setShowDups] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -67,6 +69,7 @@ export default function KbPage() {
|
||||
if (filter === "learned" && e.observations.length === 0) return false;
|
||||
if (filter === "seeded" && e.observations.length > 0) return false;
|
||||
if (filter === "unmapped" && e.productIds.length > 0) return false;
|
||||
if (filter === "review") { const c = confidenceOf(e); if (!(c.stale || (c.level === "low" && e.observations.length > 0))) return false; }
|
||||
if (!needle) return true;
|
||||
return [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase().includes(needle);
|
||||
})
|
||||
@@ -77,6 +80,7 @@ export default function KbPage() {
|
||||
if (!kb || !catalog) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading knowledge base…</div>;
|
||||
|
||||
const sel = kb.entries.find((e) => e.id === selId) ?? null;
|
||||
const dups = findDuplicates(kb);
|
||||
const learned = kb.entries.filter((e) => e.observations.length > 0).length;
|
||||
const deals = new Set(kb.entries.flatMap((e) => e.observations.map((o) => o.assessmentId))).size;
|
||||
|
||||
@@ -110,6 +114,9 @@ export default function KbPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button className="btn" onClick={() => setShowDups((v) => !v)}>
|
||||
Possible duplicates{dups.length ? ` · ${dups.length}` : ""}
|
||||
</button>
|
||||
<button className="btn" onClick={exportKb}>Export</button>
|
||||
<button className="btn" onClick={add}>+ Add entry</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving}>
|
||||
@@ -120,14 +127,43 @@ export default function KbPage() {
|
||||
|
||||
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
|
||||
|
||||
{showDups && (
|
||||
<div className="card fade-up mb-4 p-5">
|
||||
<div className="mb-1 flex items-center">
|
||||
<h2 className="text-[14px] font-semibold">Possible duplicates</h2>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setShowDups(false)}>Close</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
Duplicates split deal history, so defaults learn slower. Merging keeps the entry you pick, turns the other’s
|
||||
name into an alias and moves its deal history over. Save changes afterwards.
|
||||
</p>
|
||||
{dups.length === 0 ? <p className="text-[13px] text-[var(--text-muted)]">No likely duplicates.</p> : (
|
||||
<ul className="scroll-thin max-h-[360px] space-y-1.5 overflow-y-auto">
|
||||
{dups.slice(0, 60).map((d) => (
|
||||
<li key={pairKey(d.a.id, d.b.id)} className="flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-[12.5px]">
|
||||
<span className="min-w-0 flex-1">
|
||||
<strong>{d.a.label}</strong> <span className="text-[var(--text-muted)]">({d.a.observations.length} deals)</span>
|
||||
{" · "}<strong>{d.b.label}</strong> <span className="text-[var(--text-muted)]">({d.b.observations.length} deals)</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-muted)]">{d.reason}</span>
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={() => mutate((k) => { Object.assign(k, mergeEntries(k, d.a.id, d.b.id)); })}>Keep “{d.a.label}”</button>
|
||||
<button className="btn btn-sm" onClick={() => mutate((k) => { Object.assign(k, mergeEntries(k, d.b.id, d.a.id)); })}>Keep “{d.b.label}”</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => mutate((k) => { k.distinctPairs = [...(k.distinctPairs ?? []), pairKey(d.a.id, d.b.id)]; })}>These are different</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input className="field max-w-[320px]" placeholder="Search incumbent tools…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
|
||||
{(["all", "learned", "seeded", "unmapped"] as const).map((f) => (
|
||||
{(["all", "learned", "seeded", "unmapped", "review"] as const).map((f) => (
|
||||
<button key={f}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium capitalize ${filter === f ? "bg-[var(--accent)] text-white" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
className={`px-3 py-[7px] text-[12.5px] font-medium capitalize ${filter === f ? "bg-[var(--accent)] text-[var(--accent-ink)]" : "bg-[var(--surface-1)] text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}
|
||||
onClick={() => setFilter(f)}>
|
||||
{f === "all" ? "All" : f === "learned" ? "Proven in deals" : f === "seeded" ? "Not yet used" : "Unmapped"}
|
||||
{f === "all" ? "All" : f === "learned" ? "Proven in deals" : f === "seeded" ? "Not yet used" : f === "unmapped" ? "Unmapped" : "Needs review"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -145,7 +181,7 @@ export default function KbPage() {
|
||||
<tr className="border-b">
|
||||
<th className="py-2.5 pl-4 text-left font-semibold">Incumbent tool</th>
|
||||
<th className="py-2.5 text-left font-semibold">Maps to</th>
|
||||
<th className="w-[96px] py-2.5 pr-4 text-right font-semibold">Confidence</th>
|
||||
<th className="w-[110px] py-2.5 pr-4 text-right font-semibold">Confidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -163,7 +199,7 @@ export default function KbPage() {
|
||||
? e.productIds.map((id) => catalog.products.find((p) => p.id === id)?.name ?? id).join(", ")
|
||||
: <span className="text-[var(--text-muted)]">—</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right"><ConfidenceChip entry={e} /></td>
|
||||
<td className="py-2 pr-4 text-right"><LevelChip entry={e} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -258,6 +294,21 @@ function EntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Total cost defaults</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Num label="Infra (% of licence)" value={entry.tco?.infraPct ?? TCO_FALLBACK.infraPct} min={0} max={200}
|
||||
onChange={(n) => set("tco", { ...(entry.tco ?? TCO_FALLBACK), infraPct: n })} />
|
||||
<Num label="Admin (FTEs)" value={entry.tco?.adminFte ?? TCO_FALLBACK.adminFte} min={0} max={50} step={0.05}
|
||||
onChange={(n) => set("tco", { ...(entry.tco ?? TCO_FALLBACK), adminFte: n })} />
|
||||
<Num label="Other (% of licence)" value={entry.tco?.otherPct ?? TCO_FALLBACK.otherPct} min={0} max={200}
|
||||
onChange={(n) => set("tco", { ...(entry.tco ?? TCO_FALLBACK), otherPct: n })} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--text-muted)]">Copied onto new assessment lines for this tool; sellers can override per line.</p>
|
||||
</div>
|
||||
|
||||
<SourcePanel entry={entry} set={set} />
|
||||
|
||||
<div>
|
||||
<label className="label">Known risks & dependencies</label>
|
||||
<textarea className="field" rows={3} value={entry.risks} onChange={(e) => set("risks", e.target.value)} />
|
||||
@@ -325,3 +376,50 @@ function Num({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LEVEL_STYLE = { high: "var(--success-text)", medium: "var(--accent)", low: "var(--text-muted)" } as const;
|
||||
|
||||
function LevelChip({ entry }: { entry: KbEntry }) {
|
||||
const c = confidenceOf(entry);
|
||||
return (
|
||||
<span className="chip shrink-0" style={{ borderColor: LEVEL_STYLE[c.level], color: LEVEL_STYLE[c.level] }}
|
||||
title={`${c.source}${c.overridden ? " · set by curator" : ""}${c.stale ? " · not reviewed in 12 months" : ""}`}>
|
||||
{c.level[0].toUpperCase() + c.level.slice(1)}{c.stale ? " · review" : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SourcePanel({ entry, set }: { entry: KbEntry; set: <K extends keyof KbEntry>(k: K, v: KbEntry[K]) => void }) {
|
||||
const c = confidenceOf(entry);
|
||||
return (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Source & confidence</span>
|
||||
<LevelChip entry={entry} />
|
||||
</div>
|
||||
<p className="mb-2 text-[12px] text-[var(--text-secondary)]">{c.source}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label">Curator</label>
|
||||
<input className="field" value={entry.owner ?? ""} onChange={(e) => set("owner", e.target.value || undefined)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Reviewed</label>
|
||||
<div className="flex gap-1">
|
||||
<input className="field" type="date" value={entry.reviewedAt?.slice(0, 10) ?? ""} onChange={(e) => set("reviewedAt", e.target.value || undefined)} />
|
||||
<button className="btn btn-ghost btn-sm shrink-0" title="Mark reviewed today" onClick={() => set("reviewedAt", new Date().toISOString().slice(0, 10))}>Today</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Confidence</label>
|
||||
<select className="field" value={entry.confidenceOverride ?? ""} onChange={(e) => set("confidenceOverride", (e.target.value || undefined) as KbEntry["confidenceOverride"])}>
|
||||
<option value="">Automatic</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Assessment, AssessmentSummary } from "@/lib/types";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { Empty } from "@/components/charts";
|
||||
import { STATUS_LABEL } from "@/lib/portfolio";
|
||||
|
||||
const STATUS_COLOR = { open: "var(--accent)", won: "var(--success-text)", paused: "var(--text-muted)", lost: "var(--critical)" } as const;
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
@@ -67,7 +70,7 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
const totalSavings = (rows ?? []).reduce((s, r) => s + r.annualSavings, 0);
|
||||
const totalSavings = (rows ?? []).filter((r) => r.status !== "lost").reduce((s, r) => s + r.annualSavings, 0);
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
@@ -121,15 +124,18 @@ export default function Home() {
|
||||
<Link href={`/assessments/${r.id}`} className="block">
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 className="truncate text-[15.5px] font-semibold tracking-tight">{r.customerName}</h2>
|
||||
<span className="chip ml-auto shrink-0">{r.candidateCount} tools</span>
|
||||
{r.status && r.status !== "open" && (
|
||||
<span className="chip ml-auto shrink-0" style={{ borderColor: STATUS_COLOR[r.status], color: STATUS_COLOR[r.status] }}>{STATUS_LABEL[r.status]}</span>
|
||||
)}
|
||||
<span className={`chip shrink-0 ${r.status && r.status !== "open" ? "" : "ml-auto"}`}>{r.candidateCount} tools</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend assessed</div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Licence spend</div>
|
||||
<div className="tabnum text-[16px] font-semibold">{money(r.annualSpend, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Annual saving</div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Net annual benefit</div>
|
||||
<div className="tabnum text-[16px] font-semibold" style={{ color: "var(--success-text)" }}>{money(r.annualSavings, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
110
src/app/portfolio/page.tsx
Normal file
110
src/app/portfolio/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Portfolio } from "@/lib/portfolio";
|
||||
import { STATUS_LABEL } from "@/lib/portfolio";
|
||||
import { money } from "@/lib/format";
|
||||
import { ChartFrame, Empty, HBar, StatTile } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson } from "@/lib/client";
|
||||
|
||||
const STATUS_COLOR = { open: "var(--accent)", won: "var(--success-text)", paused: "var(--text-muted)", lost: "var(--critical)" } as const;
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const [p, setP] = useState<Portfolio | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => { getJson<Portfolio>("/api/portfolio").then(setP).catch((e) => setErr(errorMessage(e))); }, [attempt]);
|
||||
|
||||
if (err) return <LoadError message={err} onRetry={() => { setErr(""); setAttempt((n) => n + 1); }} />;
|
||||
if (!p) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading portfolio…</div>;
|
||||
|
||||
const t = p.totals;
|
||||
return (
|
||||
<div className="fade-up">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Portfolio</h1>
|
||||
<p className="mt-1 max-w-3xl text-[13.5px] text-[var(--text-secondary)]">
|
||||
Every assessment, valued as presented to its customer. Totals exclude lost deals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{p.rows.length === 0 ? <Empty>No assessments yet.</Empty> : (
|
||||
<>
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatTile label="Accounts in play" value={String(t.accounts)} sub={`${p.rows.length} assessments in total`} />
|
||||
<StatTile label="Net annual benefit" value={money(t.netAnnual, true)} tone="good" sub="At full run-rate, summed" />
|
||||
<StatTile label="Plan-adjusted net" value={money(t.horizonNet, true)} tone="accent" sub="Over each account's horizon" />
|
||||
<StatTile label="Cost in play" value={money(t.tco, true)} sub={`${t.tools} tools · ${t.vendors} vendor relationships`} />
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{p.byStatus.map((s) => (
|
||||
<div key={s.status} className="card flex items-center gap-3 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: STATUS_COLOR[s.status] }} />
|
||||
<span className="text-[13px] font-semibold">{STATUS_LABEL[s.status]}</span>
|
||||
<span className="ml-auto tabnum text-[12.5px] text-[var(--text-secondary)]">{s.accounts} · {money(s.netAnnual, true)}/yr</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-4 lg:grid-cols-2">
|
||||
<ChartFrame title="Value by business unit" subtitle="Net annual benefit across open, won and paused accounts."
|
||||
table={{ headers: ["Business unit", "Accounts", "Net annual benefit"], rows: p.byBu.map((b) => [b.name, b.accounts, money(b.savings)]) }}>
|
||||
{p.byBu.length ? <HBar data={p.byBu.map((b) => ({ label: b.name, value: b.savings, color: b.color, note: `${b.accounts} account${b.accounts === 1 ? "" : "s"}` }))} /> : <Empty />}
|
||||
</ChartFrame>
|
||||
|
||||
<section className="card p-5">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Most displaced competitors</h3>
|
||||
<p className="mb-3 mt-0.5 text-[12.5px] text-[var(--text-muted)]">Where enablement and battlecards pay off most.</p>
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead><tr className="border-b text-[var(--text-secondary)]">
|
||||
<th className="pb-2 text-left font-semibold">Competitor</th>
|
||||
<th className="pb-2 pl-3 text-right font-semibold">Accounts</th>
|
||||
<th className="pb-2 pl-3 text-right font-semibold">Current cost</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{p.competitors.map((c) => (
|
||||
<tr key={c.label} className="border-b border-[var(--grid)] last:border-0">
|
||||
<td className="py-1.5 font-medium">{c.label}</td>
|
||||
<td className="py-1.5 pl-3 text-right tabnum">{c.accounts}</td>
|
||||
<td className="py-1.5 pl-3 text-right tabnum">{money(c.tco, true)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card overflow-hidden">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="bg-[var(--surface-2)]">
|
||||
<tr className="border-b">
|
||||
{["Account", "Status", "Scenario", "Tools", "Current cost", "Net / yr", "Plan-adjusted net", "NPV"].map((h, i) => (
|
||||
<th key={h} className={`px-4 py-2.5 font-semibold ${i >= 3 ? "text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-[var(--grid)] last:border-0 hover:bg-[var(--surface-2)]">
|
||||
<td className="px-4 py-2.5 font-medium"><Link href={`/assessments/${r.id}`} className="hover:underline">{r.customerName}</Link></td>
|
||||
<td className="px-4 py-2.5"><span className="chip" style={{ borderColor: STATUS_COLOR[r.status], color: STATUS_COLOR[r.status] }}>{STATUS_LABEL[r.status]}</span></td>
|
||||
<td className="px-4 py-2.5 text-[var(--text-secondary)]">{r.scenario}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{r.toolsBefore} → {r.toolsAfter}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{money(r.tco, true)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum font-semibold" style={{ color: "var(--success-text)" }}>{money(r.netAnnual, true)}</td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{money(r.horizonNet, true)} <span className="text-[11px] text-[var(--text-muted)]">/{r.horizonYears}y</span></td>
|
||||
<td className="px-4 py-2.5 text-right tabnum">{money(r.npv, true)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -329,7 +329,7 @@ function CategoryStep({
|
||||
<button key={o.id} onClick={() => toggle(o.label, o.id)} aria-pressed={on}
|
||||
className="rounded-full border px-3 py-1.5 text-[13px] font-medium transition-colors"
|
||||
style={on
|
||||
? { background: "var(--accent)", borderColor: "var(--accent)", color: "#fff" }
|
||||
? { background: "var(--accent)", borderColor: "var(--accent)", color: "var(--accent-ink)" }
|
||||
: { borderColor: "var(--border-strong)", color: "var(--text-primary)" }}>
|
||||
{on ? "✓ " : ""}{o.label}
|
||||
</button>
|
||||
|
||||
65
src/app/settings/page.tsx
Normal file
65
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Branding } from "@/lib/types";
|
||||
import { BRAND_SWATCHES } from "@/lib/brand";
|
||||
import { LogoInput } from "@/components/LogoInput";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [b, setB] = useState<Branding | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
useEffect(() => { getJson<Branding>("/api/settings").then(setB).catch((e) => setLoadError(errorMessage(e))); }, []);
|
||||
const set = (patch: Partial<Branding>) => { setB((prev) => (prev ? { ...prev, ...patch } : prev)); setDirty(true); setErr(""); };
|
||||
|
||||
async function save() {
|
||||
if (!b) return;
|
||||
setSaving(true);
|
||||
try { setB(await sendJson<Branding>("/api/settings", "PUT", b)); setDirty(false); }
|
||||
catch (e) { setErr(errorMessage(e)); } finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => location.reload()} />;
|
||||
if (!b) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading settings…</div>;
|
||||
|
||||
return (
|
||||
<div className="fade-up max-w-[760px]">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Settings</h1>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--text-secondary)]">How “us” appears on customer reports, Word files and decks.</p>
|
||||
</div>
|
||||
<button className="btn btn-primary ml-auto" onClick={save} disabled={!dirty || saving}>
|
||||
{saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
|
||||
|
||||
<section className="card space-y-5 p-5">
|
||||
<div>
|
||||
<label className="label" htmlFor="st-name">Company name</label>
|
||||
<input id="st-name" className="field max-w-[360px]" placeholder="e.g. Broadcom" value={b.companyName} onChange={(e) => set({ companyName: e.target.value })} />
|
||||
</div>
|
||||
<LogoInput label="Our logo (PNG or JPEG, under 600 KB — use a version you are authorised to use)" value={b.logo} onChange={(logo) => set({ logo })} />
|
||||
<div>
|
||||
<span className="label">Accent colour</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{BRAND_SWATCHES.map((s) => (
|
||||
<button key={s.hex} onClick={() => set({ accent: s.hex })} title={`${s.name} ${s.hex}`}
|
||||
className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[12px]"
|
||||
style={b.accent.toLowerCase() === s.hex.toLowerCase() ? { borderColor: "var(--text-primary)", boxShadow: "0 0 0 1px var(--text-primary)" } : undefined}>
|
||||
<span className="h-4 w-4 rounded" style={{ background: s.hex }} />{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11.5px] text-[var(--text-muted)]">Brand palette only. Used for headings and highlights on reports and decks.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import { useRef, useState } from "react";
|
||||
import type { Assessment, Catalog, PlanOverride } from "@/lib/types";
|
||||
import type { Rollup } from "@/lib/scoring";
|
||||
import type { Evaluation } from "@/lib/finance";
|
||||
import { money } from "@/lib/format";
|
||||
import { ChartFrame, DataTable, Empty, StatTile } from "@/components/charts";
|
||||
import { type Plan, type PlanItem, DEFAULT_PARALLEL, DURATION_BY_EASE, buildPlan, monthLabel, quarterLabel } from "@/lib/timeline";
|
||||
import { type Plan, type PlanItem, DEFAULT_PARALLEL, DURATION_BY_EASE, monthKey, monthLabel, quarterLabel } from "@/lib/timeline";
|
||||
|
||||
/* -------------------------------------------------------- tool footprint */
|
||||
|
||||
@@ -17,7 +18,7 @@ export function ToolFootprintTile({ r }: { r: Rollup }) {
|
||||
label="Tool footprint"
|
||||
value={`${r.toolsRetired} → ${r.toolsAfter}`}
|
||||
tone={cut > 0 ? "good" : "neutral"}
|
||||
sub={`${cut > 0 ? `${cut} fewer tools (${pct}%)` : cut < 0 ? `${-cut} more products than tools` : "Same tool count"} · ${r.vendorsRetired} vendors → ${r.vendorsAfter}${r.unmappedCount ? ` · ${r.unmappedCount} unmapped` : ""}`}
|
||||
sub={`${cut > 0 ? `${cut} fewer tool${cut === 1 ? "" : "s"} (${pct}%)` : cut < 0 ? `${-cut} more products than tools` : "Same tool count"} · ${r.vendorsRetired} vendors → ${r.vendorsAfter}${r.unmappedCount ? ` · ${r.unmappedCount} unmapped` : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -91,18 +92,20 @@ function Num({ label, v, strong }: { label: string; v: string; strong?: boolean
|
||||
const PHASE_MONTHS = 6;
|
||||
|
||||
export function ImplementationTimeline({
|
||||
a, r, catalog, onParallel,
|
||||
e, catalog, onPlan, onParallel, onResetAll,
|
||||
}: {
|
||||
a: Assessment;
|
||||
r: Rollup;
|
||||
e: Evaluation;
|
||||
catalog: Catalog;
|
||||
/** Workbench only: lets the seller tune how many migrations run at once. */
|
||||
/** Workbench only: set (or clear, with null) a line's plan overrides. */
|
||||
onPlan?: (id: string, patch: PlanOverride | null) => void;
|
||||
onParallel?: (n: number) => void;
|
||||
onResetAll?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [asTable, setAsTable] = useState(false);
|
||||
const plan = useMemo(() => buildPlan(a, r, catalog), [a, r, catalog]);
|
||||
const { plan, a } = e;
|
||||
const L = (n: number, long = false) => monthLabel(plan.startIso, n, long);
|
||||
const edited = plan.items.filter((i) => i.edited).length;
|
||||
|
||||
const summary = plan.items.length
|
||||
? `${plan.items.length} projects · ${L(0)} – ${L(plan.completion - 1)} · full run-rate savings from ${L(plan.fullRunRate)}`
|
||||
@@ -117,11 +120,14 @@ export function ImplementationTimeline({
|
||||
<span className="block text-[14px] font-semibold tracking-tight">Implementation timeline</span>
|
||||
<span className="block truncate text-[12.5px] text-[var(--text-muted)]">{summary}</span>
|
||||
</span>
|
||||
{plan.bridgeCount > 0 && (
|
||||
<span className="chip ml-auto shrink-0" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>
|
||||
{plan.bridgeCount} need a bridge extension
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 gap-1.5">
|
||||
{plan.conflictCount > 0 && (
|
||||
<span className="chip" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{plan.conflictCount} dependency conflict{plan.conflictCount === 1 ? "" : "s"}</span>
|
||||
)}
|
||||
{plan.bridgeCount > 0 && (
|
||||
<span className="chip" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>{plan.bridgeCount} need a bridge extension</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Collapsed on screen, but always part of the printed report. */}
|
||||
@@ -130,9 +136,9 @@ export function ImplementationTimeline({
|
||||
<>
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Mini label="Program" value={`${plan.completion} months`} sub={`${L(0)} – ${L(plan.completion - 1)}`} />
|
||||
<Mini label="Full run-rate savings" value={L(plan.fullRunRate)} sub={`${money(plan.ramp[plan.ramp.length - 1] ?? 0, true)} per year once complete`} />
|
||||
<Mini label={`Realized in ${a.horizonYears} years`} value={money(plan.realizedInHorizon, true)}
|
||||
sub={`${plan.flatInHorizon ? Math.round((plan.realizedInHorizon / plan.flatInHorizon) * 100) : 0}% of the ${money(plan.flatInHorizon, true)} flat model, after ramp-up`} />
|
||||
<Mini label="Full run-rate savings" value={L(plan.fullRunRate)} sub={`${money(e.cash.runRateNet, true)} net per year once complete`} />
|
||||
<Mini label={`Realized in ${a.horizonYears} years`} value={money(e.cash.horizonNet, true)}
|
||||
sub={`Net of one-time costs; NPV ${money(e.cash.npv, true)}`} />
|
||||
<Mini label="Contract-driven" value={`${plan.items.filter((i) => i.anchor === "contract").length} of ${plan.items.length}`}
|
||||
sub={plan.bridgeCount ? `${plan.bridgeCount} expire before migration can finish` : "Every dated contract is met"} />
|
||||
</div>
|
||||
@@ -144,21 +150,25 @@ export function ImplementationTimeline({
|
||||
<div className="flex overflow-hidden rounded-lg border border-[var(--border-strong)]">
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<button key={n} onClick={() => onParallel(n)}
|
||||
className={`px-2.5 py-1 text-[12px] font-semibold ${(a.planParallel ?? DEFAULT_PARALLEL) === n ? "bg-[var(--accent)] text-white" : "hover:bg-[var(--surface-2)]"}`}>{n}</button>
|
||||
className={`px-2.5 py-1 text-[12px] font-semibold ${(a.planParallel ?? DEFAULT_PARALLEL) === n ? "bg-[var(--accent)] text-[var(--accent-ink)]" : "hover:bg-[var(--surface-2)]"}`}>{n}</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{onPlan && <span className="text-[11.5px] text-[var(--text-muted)]">Drag a bar to move it, its right edge to resize, or click a project to edit.</span>}
|
||||
{onResetAll && edited > 0 && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onResetAll}>Reset {edited} edit{edited === 1 ? "" : "s"}</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setAsTable((v) => !v)}>{asTable ? "Timeline" : "Table"}</button>
|
||||
</div>
|
||||
|
||||
{asTable ? <PlanTable plan={plan} /> : <Gantt plan={plan} catalog={catalog} />}
|
||||
{asTable ? <PlanTable plan={plan} /> : <Gantt plan={plan} catalog={catalog} onPlan={onPlan} />}
|
||||
|
||||
<p className="mt-4 text-[11.5px] leading-relaxed text-[var(--text-muted)]">
|
||||
Tools with a known contract end are scheduled to cut over by that date. The rest are sequenced with known
|
||||
savings first, then by wave and size of saving, with {a.planParallel ?? DEFAULT_PARALLEL} migration{(a.planParallel ?? DEFAULT_PARALLEL) === 1 ? "" : "s"} in
|
||||
flight per business unit. Migration length follows ease of switching ({Object.entries(DURATION_BY_EASE).reverse().map(([k, v]) => `${k}/5: ${v} mo`).join(", ")}).
|
||||
Savings start at cutover, or at contract end if later.
|
||||
flight per business unit. Migration length follows ease of switching ({Object.entries(DURATION_BY_EASE).reverse().map(([k, v]) => `${k}/5: ${v} mo`).join(", ")})
|
||||
unless set by hand. Savings start at cutover, or at contract end if later.
|
||||
{plan.unmappedCount > 0 && ` ${plan.unmappedCount} unmapped tool${plan.unmappedCount === 1 ? " is" : "s are"} left out until a replacement is chosen.`}
|
||||
</p>
|
||||
</>
|
||||
@@ -178,9 +188,14 @@ function Mini({ label, value, sub }: { label: string; value: string; sub: string
|
||||
);
|
||||
}
|
||||
|
||||
function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
const span = plan.span;
|
||||
type Drag = { id: string; mode: "move" | "resize"; x0: number; perMonth: number; start: number; end: number; dStart: number; dEnd: number };
|
||||
|
||||
function Gantt({ plan, catalog, onPlan }: { plan: Plan; catalog: Catalog; onPlan?: (id: string, patch: PlanOverride | null) => void }) {
|
||||
const span = Math.max(plan.span, ...plan.items.map((i) => i.end + 1));
|
||||
const pct = (m: number) => `${(Math.min(Math.max(m, 0), span) / span) * 100}%`;
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const moved = useRef(false);
|
||||
|
||||
// Quarter ticks, labelled at each quarter boundary inside the span.
|
||||
const ticks: number[] = [];
|
||||
@@ -195,9 +210,37 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
|
||||
const buLegend = catalog.businessUnits.filter((b) => plan.items.some((i) => i.color === b.color));
|
||||
const peak = Math.max(1, ...plan.ramp);
|
||||
const labelOf = (id: string) => plan.items.find((i) => i.id === id)?.label ?? id;
|
||||
|
||||
const Track = ({ children, className = "" }: { children?: React.ReactNode; className?: string }) => (
|
||||
<div className={`relative ${className}`}>
|
||||
function begin(ev: React.PointerEvent, it: PlanItem, mode: Drag["mode"]) {
|
||||
if (!onPlan) return;
|
||||
ev.stopPropagation();
|
||||
const track = (ev.currentTarget as HTMLElement).closest("[data-track]") as HTMLElement;
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
moved.current = false;
|
||||
setDrag({ id: it.id, mode, x0: ev.clientX, perMonth: track.getBoundingClientRect().width / span, start: it.start, end: it.end, dStart: it.start, dEnd: it.end });
|
||||
}
|
||||
function move(ev: React.PointerEvent) {
|
||||
if (!drag) return;
|
||||
const d = Math.round((ev.clientX - drag.x0) / drag.perMonth);
|
||||
if (d !== 0) moved.current = true;
|
||||
setDrag(drag.mode === "move"
|
||||
? { ...drag, dStart: Math.max(0, drag.start + d), dEnd: Math.max(0, drag.start + d) + (drag.end - drag.start) }
|
||||
: { ...drag, dEnd: Math.max(drag.start + 1, drag.end + d) });
|
||||
}
|
||||
function end() {
|
||||
if (!drag || !onPlan) return;
|
||||
const it = plan.items.find((i) => i.id === drag.id);
|
||||
if (moved.current && it) {
|
||||
onPlan(drag.id, { start: monthKey(plan.startIso, drag.dStart), duration: drag.dEnd - drag.dStart, after: it.after.length ? it.after : undefined });
|
||||
}
|
||||
setDrag(null);
|
||||
}
|
||||
|
||||
// A render helper, not a component: a component defined here would be a new
|
||||
// type every render, remounting the bars and dropping pointer capture mid-drag.
|
||||
const track = (className: string, children: React.ReactNode) => (
|
||||
<div data-track className={`relative ${className}`}>
|
||||
{ticks.map((m) => <div key={m} className="absolute inset-y-0 w-px bg-[var(--grid)]" style={{ left: pct(m) }} />)}
|
||||
{children}
|
||||
</div>
|
||||
@@ -205,7 +248,7 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
|
||||
return (
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="min-w-[760px]" onPointerMove={move} onPointerUp={end} onPointerCancel={() => setDrag(null)}>
|
||||
{/* axis */}
|
||||
<div className="grid grid-cols-[240px_minmax(0,1fr)] border-b pb-1.5">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Project</div>
|
||||
@@ -228,38 +271,57 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
{items.length} project{items.length === 1 ? "" : "s"} · {money(items.reduce((s, i) => s + i.annualSavings, 0), true)}/yr unlocked
|
||||
</div>
|
||||
</div>
|
||||
{items.map((i) => (
|
||||
<div key={i.id} className="grid grid-cols-[240px_minmax(0,1fr)] border-b border-[var(--grid)]">
|
||||
<div className="min-w-0 py-1.5 pr-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{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>}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-[var(--text-muted)]">→ {i.products}</div>
|
||||
</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 }} />
|
||||
{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]"
|
||||
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)}`}
|
||||
<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(i.start), width: `calc(${pct(i.end)} - ${pct(i.start)})`, background: i.color,
|
||||
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="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>
|
||||
<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="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 border-2"
|
||||
<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)}`} />
|
||||
)}
|
||||
</Track>
|
||||
</>)}
|
||||
</div>
|
||||
))}
|
||||
{editing === i.id && onPlan && (
|
||||
<RowEditor it={i} plan={plan} onPlan={onPlan} onClose={() => setEditing(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -269,13 +331,13 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
<div className="font-semibold">Run-rate savings</div>
|
||||
<div className="text-[var(--text-muted)]">{money(peak, true)}/yr at full run-rate</div>
|
||||
</div>
|
||||
<Track className="h-14">
|
||||
{track("h-14",
|
||||
<svg className="absolute inset-0 h-full w-full" viewBox={`0 0 ${span} 100`} preserveAspectRatio="none" aria-hidden>
|
||||
<path
|
||||
d={`M0 100 ${plan.ramp.map((v, m) => `L${m} ${100 - (v / peak) * 92} L${m + 1} ${100 - (v / peak) * 92}`).join(" ")} L${span} ${100 - ((plan.ramp[plan.ramp.length - 1] ?? 0) / peak) * 92} L${span} 100 Z`}
|
||||
d={`M0 100 ${plan.ramp.map((v, m) => `L${m} ${100 - (Math.max(0, v) / peak) * 92} L${m + 1} ${100 - (Math.max(0, v) / peak) * 92}`).join(" ")} L${span} ${100 - (Math.max(0, plan.ramp[plan.ramp.length - 1] ?? 0) / peak) * 92} L${span} 100 Z`}
|
||||
fill="var(--good)" fillOpacity=".18" stroke="var(--success-text)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</Track>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* legend */}
|
||||
@@ -286,19 +348,65 @@ function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
<span className="flex items-center gap-1.5"><span className="h-2 w-2 rotate-45 border-2 border-[var(--text-primary)]" />Contract ends</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-4 border-t-2 border-dashed border-[var(--text-muted)]" />Waiting for contract end</span>
|
||||
<span className="flex items-center gap-1.5"><span style={{ color: "var(--warning)" }}>⚠</span>Bridge extension needed</span>
|
||||
{onPlan && <span className="flex items-center gap-1.5"><span className="text-[var(--accent)]">●</span>Edited by hand</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowEditor({ it, plan, onPlan, onClose }: { it: PlanItem; plan: Plan; onPlan: (id: string, patch: PlanOverride | null) => void; onClose: () => void }) {
|
||||
const months = Array.from({ length: 48 }, (_, n) => n);
|
||||
const others = plan.items.filter((x) => x.id !== it.id);
|
||||
const pinned = it.anchor === "pinned";
|
||||
const base = { start: pinned ? monthKey(plan.startIso, it.start) : undefined, duration: it.end - it.start, after: it.after.length ? it.after : undefined };
|
||||
const set = (p: Partial<PlanOverride>) => onPlan(it.id, { ...base, ...p });
|
||||
|
||||
return (
|
||||
<div className="fade-up no-print grid gap-3 bg-[var(--surface-2)] px-3 py-3 sm:grid-cols-[180px_120px_minmax(0,1fr)_auto]">
|
||||
<div>
|
||||
<label className="label" htmlFor={`pe-s-${it.id}`}>Start</label>
|
||||
<select id={`pe-s-${it.id}`} className="field" value={pinned ? String(it.start) : ""}
|
||||
onChange={(ev) => set({ start: ev.target.value === "" ? undefined : monthKey(plan.startIso, Number(ev.target.value)) })}>
|
||||
<option value="">Automatic ({monthLabel(plan.startIso, it.start)})</option>
|
||||
{months.map((n) => <option key={n} value={n}>{monthLabel(plan.startIso, n)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`pe-d-${it.id}`}>Months</label>
|
||||
<input id={`pe-d-${it.id}`} className="field tabnum" type="number" min={1} max={36} value={it.end - it.start}
|
||||
onChange={(ev) => set({ duration: Math.min(36, Math.max(1, Number(ev.target.value) || 1)) })} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="label">Starts after</span>
|
||||
<div className="scroll-thin flex max-h-[92px] flex-wrap gap-1 overflow-y-auto">
|
||||
{others.map((o) => {
|
||||
const on = it.after.includes(o.id);
|
||||
return (
|
||||
<button key={o.id} className="rounded-full border px-2 py-0.5 text-[11.5px]"
|
||||
style={on ? { background: "var(--accent)", borderColor: "var(--accent)", color: "var(--accent-ink)" } : { borderColor: "var(--border-strong)" }}
|
||||
onClick={() => set({ after: on ? it.after.filter((x) => x !== o.id) : [...it.after, o.id] })}>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-1.5">
|
||||
<button className="btn btn-sm" onClick={() => { onPlan(it.id, null); onClose(); }} disabled={!it.edited}>Reset row</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanTable({ plan }: { plan: Plan }) {
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
return (
|
||||
<DataTable
|
||||
headers={["Project", "Replacement", "Start", "Cutover", "Contract ends", "Savings from", "Annual saving"]}
|
||||
headers={["Project", "Replacement", "Start", "Cutover", "Contract ends", "Savings from", "Net annual"]}
|
||||
rows={plan.items.map((i) => [
|
||||
`${i.label}${i.bridge ? " ⚠" : ""}`, i.products, L(i.start), L(i.end - 1),
|
||||
`${i.label}${i.bridge ? " ⚠" : ""}${i.edited ? " ●" : ""}`, i.products, L(i.start), L(i.end - 1),
|
||||
i.contractEnd === null ? "—" : L(i.contractEnd), L(i.savingsFrom), money(i.annualSavings),
|
||||
])}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
|
||||
import type { CandidateMetrics } from "@/lib/scoring";
|
||||
import { type CandidateMetrics, TCO_FALLBACK } from "@/lib/scoring";
|
||||
import { confidenceOf, findEntry } from "@/lib/kb";
|
||||
import { InternalBadge } from "./Finance";
|
||||
import { CAPABILITY_LABEL, SWITCH_LABEL, money, monthsLabel } from "@/lib/format";
|
||||
import ProductPicker, { suggest } from "./ProductPicker";
|
||||
import IncumbentPicker, { ConfidenceChip } from "./IncumbentPicker";
|
||||
@@ -53,6 +55,7 @@ export default function CandidateRow({
|
||||
migrationCost: e.migrationCost || c.migrationCost,
|
||||
unitLabel: e.unitLabel || c.unitLabel,
|
||||
risks: c.risks.trim() || e.risks,
|
||||
tcoDefaults: { ...(e.tco ?? TCO_FALLBACK) },
|
||||
});
|
||||
setApplied(e);
|
||||
}
|
||||
@@ -61,6 +64,13 @@ export default function CandidateRow({
|
||||
.filter((p) => !c.productIds.includes(p.id));
|
||||
const mapped = c.productIds.map((id) => catalog.products.find((p) => p.id === id)).filter(Boolean);
|
||||
const title = [c.incumbentVendor, c.incumbentProduct].filter(Boolean).join(" ") || "Untitled incumbent tool";
|
||||
const entry = findEntry(kb, title);
|
||||
const conf = entry ? confidenceOf(entry) : null;
|
||||
const d = c.tcoDefaults ?? TCO_FALLBACK;
|
||||
const setTco = (k: "infra" | "adminFte" | "other", v: number | null) =>
|
||||
onChange({ ...c, tco: { infra: c.tco?.infra ?? null, adminFte: c.tco?.adminFte ?? null, other: c.tco?.other ?? null, [k]: v } });
|
||||
const setInv = (k: "annual" | "services", v: number | null) =>
|
||||
onChange({ ...c, investment: { annual: c.investment?.annual ?? null, services: c.investment?.services ?? null, [k]: v } });
|
||||
|
||||
return (
|
||||
<div className={`card avoid-break overflow-hidden transition-opacity ${c.included ? "" : "opacity-55"}`}>
|
||||
@@ -71,6 +81,10 @@ export default function CandidateRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[14px] font-semibold tracking-tight">{title}</span>
|
||||
{!c.included && <span className="chip">Excluded</span>}
|
||||
{c.alreadyEntitled && (
|
||||
<span className="chip" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }}
|
||||
title={`The customer already owns unused ${c.alreadyEntitled} licences — no new subscription for this line`}>Already entitled</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-[var(--text-muted)]">
|
||||
{mapped.length ? `→ ${mapped.map((p) => p!.name).join(", ")}` : "No replacement mapped yet"}
|
||||
@@ -79,11 +93,11 @@ export default function CandidateRow({
|
||||
|
||||
<div className="hidden shrink-0 items-center gap-5 text-right sm:flex">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Spend</div>
|
||||
<div className="tabnum text-[13px] font-semibold">{money(c.annualSpend, true)}</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">TCO</div>
|
||||
<div className="tabnum text-[13px] font-semibold" title={`Licence ${money(m.licence)} · infra ${money(m.infra)} · admin ${money(m.admin)} · other ${money(m.other)}`}>{money(m.tco, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Saving</div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Net / yr</div>
|
||||
<div className="tabnum text-[13px] font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -181,6 +195,33 @@ export default function CandidateRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Total cost and pricing ---- */}
|
||||
<div className="mt-5 border-t pt-4">
|
||||
<div className="mb-2 flex flex-wrap items-baseline gap-2">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Total cost of ownership</h4>
|
||||
<span className="tabnum text-[12px] text-[var(--text-secondary)]">
|
||||
{money(m.licence)} licence + {money(m.infra)} infra + {money(m.admin)} admin + {money(m.other)} other = <strong>{money(m.tco)}</strong>/yr
|
||||
</span>
|
||||
{conf && (
|
||||
<span className="ml-auto text-[11.5px] text-[var(--text-muted)]" title={conf.stale ? "Not reviewed in the last 12 months" : undefined}>
|
||||
Defaults: <span style={{ color: conf.level === "high" ? "var(--success-text)" : conf.level === "medium" ? "var(--accent)" : "var(--text-muted)" }}>{conf.level} confidence</span> · {conf.source}{conf.stale ? " · needs review" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<TcoField id={`ti-${c.id}`} label="Infrastructure (USD)" value={c.tco?.infra ?? null} fallback={Math.round(m.licence * d.infraPct / 100)}
|
||||
hint={`Default ${d.infraPct}% of licence`} step={1000} onChange={(v) => setTco("infra", v)} />
|
||||
<TcoField id={`ta-${c.id}`} label="Admin (FTEs)" value={c.tco?.adminFte ?? null} fallback={m.licence > 0 ? d.adminFte : 0}
|
||||
hint={`${money(m.admin, true)} at the assessment FTE cost`} step={0.05} onChange={(v) => setTco("adminFte", v)} />
|
||||
<TcoField id={`to-${c.id}`} label="Other run costs (USD)" value={c.tco?.other ?? null} fallback={Math.round(m.licence * d.otherPct / 100)}
|
||||
hint={`Default ${d.otherPct}% — training, integrations`} step={1000} onChange={(v) => setTco("other", v)} />
|
||||
<TcoField id={`ia-${c.id}`} label={<>Our price / yr <InternalBadge /></>} value={c.investment?.annual ?? null} fallback={Math.round(m.subscription)}
|
||||
hint={c.alreadyEntitled ? "Already entitled — none by default" : "Blank = share of the deal-level price"} step={1000} onChange={(v) => setInv("annual", v)} />
|
||||
<TcoField id={`is-${c.id}`} label={<>Our services <InternalBadge /></>} value={c.investment?.services ?? null} fallback={Math.round(m.services)}
|
||||
hint="Blank = share of deal-level services" step={1000} onChange={(v) => setInv("services", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Scoring ---- */}
|
||||
<div className="mt-5 grid gap-5 border-t pt-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Slider id={`cap-${c.id}`} label="Capability parity" value={c.capability} min={1} max={5}
|
||||
@@ -214,7 +255,7 @@ export default function CandidateRow({
|
||||
</label>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="tabnum text-[12px] text-[var(--text-muted)]">
|
||||
{money(m.annualSavings)}/yr · {monthsLabel(m.paybackMonths)} payback
|
||||
{money(m.annualSavings)}/yr net · {monthsLabel(m.paybackMonths)} payback
|
||||
{m.roiPct !== null && ` · ${m.roiPct.toFixed(0)}% ROI`}
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={onDuplicate}>Duplicate</button>
|
||||
@@ -226,3 +267,25 @@ export default function CandidateRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A cost field that shows its default until the seller types an override. */
|
||||
function TcoField({
|
||||
id, label, value, fallback, hint, step, onChange,
|
||||
}: {
|
||||
id: string; label: React.ReactNode; value: number | null; fallback: number; hint: string; step: number;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={id}>{label}</label>
|
||||
<div className="flex gap-1">
|
||||
<input id={id} className="field tabnum" type="number" min={0} step={step} placeholder={String(fallback)}
|
||||
value={value ?? ""} onChange={(e) => onChange(e.target.value === "" ? null : Math.max(0, Number(e.target.value)))} />
|
||||
{value !== null && (
|
||||
<button className="btn btn-ghost btn-sm shrink-0" title="Use the default" onClick={() => onChange(null)}>↺</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{value === null ? `Default · ${hint}` : hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
353
src/components/Finance.tsx
Normal file
353
src/components/Finance.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog, Scenario } from "@/lib/types";
|
||||
import { financeOf } from "@/lib/scoring";
|
||||
import { type Evaluation, EXPECTED_ID, allScenarios, evaluate } from "@/lib/finance";
|
||||
import { DEFAULT_PARALLEL, monthLabel } from "@/lib/timeline";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
import { StatTile } from "@/components/charts";
|
||||
import { ToolFootprintTile } from "@/components/Analysis";
|
||||
|
||||
const pct = (n: number) => `${Math.round(n)}%`;
|
||||
const paybackText = (m: number | null) => (m === null ? "Beyond horizon" : m === 0 ? "Immediate" : monthsLabel(m));
|
||||
|
||||
/* ------------------------------------------------------------ headline */
|
||||
|
||||
/** Ramp-adjusted headline: the numbers the plan actually delivers. */
|
||||
export function HeadlineTiles({ e, internal }: { e: Evaluation; internal?: boolean }) {
|
||||
const { r, cash, a } = e;
|
||||
const f = financeOf(a);
|
||||
const flat = r.totalAnnualSavings * a.horizonYears - r.totalMigrationCost;
|
||||
return (
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatTile label="Current cost (TCO)" value={money(r.addressableTco)}
|
||||
sub={`${money(r.addressableSpend, true)} licence · ${r.toolsRetired} tools · ${r.vendorsRetired} vendors`} />
|
||||
<StatTile label="Net annual benefit" value={money(cash.runRateNet)} tone="good"
|
||||
sub={`At full run-rate · ${pct(r.addressableTco ? (cash.runRateNet / r.addressableTco) * 100 : 0)} of TCO`} />
|
||||
<StatTile label={`${a.horizonYears}-year net benefit`} value={money(cash.horizonNet)} tone="accent"
|
||||
sub={`NPV ${money(cash.npv, true)} at ${f.discountRate}%${internal ? ` · flat model ${money(flat, true)}` : ""}`} />
|
||||
<StatTile label="Payback" value={paybackText(cash.paybackMonth)}
|
||||
sub={cash.irr === null ? `${money(cash.oneTimeTotal, true)} one-time investment` : `IRR ${Math.round(cash.irr)}% · ${money(cash.oneTimeTotal, true)} one-time`} />
|
||||
<ToolFootprintTile r={r} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ cash flow */
|
||||
|
||||
export function CashFlowTable({ e, showSubscription }: { e: Evaluation; showSubscription: boolean }) {
|
||||
const { cash } = e;
|
||||
const cols = showSubscription
|
||||
? ["Cost removed", "Our subscription", "One-time", "Net", "Cumulative"]
|
||||
: ["Recurring change", "One-time", "Net", "Cumulative"];
|
||||
return (
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="pb-2 text-left font-semibold text-[var(--text-secondary)]" />
|
||||
{cols.map((h) => <th key={h} className="pb-2 pl-4 text-right font-semibold text-[var(--text-secondary)]">{h}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cash.years.map((y) => (
|
||||
<tr key={y.year} className="border-b border-[var(--grid)] last:border-0">
|
||||
<td className="py-1.5 font-medium">
|
||||
Year {y.year}
|
||||
<span className="ml-1.5 text-[11px] font-normal text-[var(--text-muted)]">
|
||||
{monthLabel(e.plan.startIso, (y.year - 1) * 12)} – {monthLabel(e.plan.startIso, y.year * 12 - 1)}
|
||||
</span>
|
||||
</td>
|
||||
{showSubscription ? (
|
||||
<>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(y.removed)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">−{money(y.subscription)}</td>
|
||||
</>
|
||||
) : (
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(y.removed - y.subscription)}</td>
|
||||
)}
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{y.oneTime ? `−${money(y.oneTime)}` : "—"}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum font-semibold" style={{ color: y.net >= 0 ? "var(--success-text)" : "var(--critical)" }}>{money(y.net)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(y.cumulative)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ assumptions */
|
||||
|
||||
export function FinancePanel({ a, e, update }: { a: Assessment; e: Evaluation; update: (fn: (d: Assessment) => void) => void }) {
|
||||
const f = financeOf(a);
|
||||
const setF = (fn: (x: ReturnType<typeof financeOf>) => void) => update((d) => { const x = financeOf(d); fn(x); d.finance = x; });
|
||||
const num = (v: string) => Math.max(0, Number(v) || 0);
|
||||
|
||||
return (
|
||||
<section className="card p-5">
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Financial model</h3>
|
||||
<span className="ml-auto text-[11.5px] text-[var(--text-muted)]">
|
||||
{e.r.hasInvestment ? "Gross cost removed − our investment" : "Savings % of TCO — add our investment to switch to gross − investment"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="label" htmlFor="f-dr">Discount rate (%)</label>
|
||||
<input id="f-dr" className="field tabnum" type="number" min={0} max={50} step={0.5} value={f.discountRate}
|
||||
onChange={(ev) => setF((x) => { x.discountRate = Math.min(50, num(ev.target.value)); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="f-fte">Admin FTE cost (USD / yr)</label>
|
||||
<input id="f-fte" className="field tabnum" type="number" min={0} step={5000} value={f.fteCost}
|
||||
onChange={(ev) => setF((x) => { x.fteCost = num(ev.target.value); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="f-sub">Our subscription (USD / yr) <InternalBadge /></label>
|
||||
<input id="f-sub" className="field tabnum" type="number" min={0} step={10000} value={f.investment.annual}
|
||||
onChange={(ev) => setF((x) => { x.investment.annual = num(ev.target.value); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="f-svc">Services, one-time (USD) <InternalBadge /></label>
|
||||
<input id="f-svc" className="field tabnum" type="number" min={0} step={10000} value={f.investment.services}
|
||||
onChange={(ev) => setF((x) => { x.investment.services = num(ev.target.value); })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<CashFlowTable e={e} showSubscription={e.r.hasInvestment} />
|
||||
<p className="mt-2 text-[11.5px] text-[var(--text-muted)]">
|
||||
Ramp-adjusted from the implementation plan: savings start at each cutover, our subscription is billed from month 1,
|
||||
migration lands at each project start. The customer report shows net figures only — our investment is never itemized.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function InternalBadge() {
|
||||
return (
|
||||
<span className="ml-1 rounded px-1 py-px align-middle text-[9.5px] font-bold tracking-[.06em]"
|
||||
style={{ background: "color-mix(in oklab, var(--warning) 22%, transparent)", color: "var(--text-primary)" }}
|
||||
title="Never shown on the customer report, Word file or deck">INTERNAL</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ scenarios */
|
||||
|
||||
export function ScenarioPanel({
|
||||
a, catalog, update,
|
||||
}: {
|
||||
a: Assessment; catalog: Catalog; update: (fn: (d: Assessment) => void) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const evals = useMemo(() => allScenarios(a).map((s) => evaluate(a, catalog, s)), [a, catalog]);
|
||||
const presented = a.presentedScenario ?? EXPECTED_ID;
|
||||
|
||||
function saveCurrent() {
|
||||
const id = `s-${Date.now().toString(36)}`;
|
||||
update((d) => {
|
||||
d.scenarios = [...(d.scenarios ?? []), {
|
||||
id, name: `Scenario ${(d.scenarios?.length ?? 0) + 1}`, kind: "custom", valueFactor: 1,
|
||||
excluded: d.candidates.filter((c) => !c.included).map((c) => c.id),
|
||||
parallel: d.planParallel ?? DEFAULT_PARALLEL,
|
||||
}];
|
||||
});
|
||||
setEditing(id);
|
||||
}
|
||||
|
||||
const setS = (id: string, fn: (s: Scenario) => void) =>
|
||||
update((d) => { const s = d.scenarios?.find((x) => x.id === id); if (s) fn(s); });
|
||||
|
||||
return (
|
||||
<section className="card p-5">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Scenarios</h3>
|
||||
<label className="ml-auto flex items-center gap-2 text-[12px] text-[var(--text-secondary)]">
|
||||
<input type="checkbox" checked={!!a.showScenarioComparison}
|
||||
onChange={(ev) => update((d) => { d.showScenarioComparison = ev.target.checked; })} />
|
||||
Show comparison in customer report
|
||||
</label>
|
||||
<button className="btn btn-sm" onClick={saveCurrent} title="Captures the current scope, pace and investment">+ Save current as scenario</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12px] text-[var(--text-muted)]">
|
||||
Presets derive from the assessment as it stands. The <strong>Presented</strong> scenario drives the report, Word file and deck.
|
||||
</p>
|
||||
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-[var(--text-secondary)]">
|
||||
{["Presented", "Scenario", "Tools", "Value", "Pace", "Net / yr", `${a.horizonYears}-yr net`, "NPV", "Payback", "Complete", ""].map((h, i) => (
|
||||
<th key={i} className={`pb-2 font-semibold ${i >= 5 && i <= 9 ? "pl-3 text-right" : "pl-3 text-left first:pl-0"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evals.map((ev) => {
|
||||
const s = ev.scenario;
|
||||
return (
|
||||
<tr key={s.id} className="border-b border-[var(--grid)] last:border-0">
|
||||
<td className="py-2">
|
||||
<input type="radio" name="presented" checked={presented === s.id} aria-label={`Present ${s.name}`}
|
||||
onChange={() => update((d) => { d.presentedScenario = s.id; })} />
|
||||
</td>
|
||||
<td className="py-2 pl-3 font-medium">
|
||||
{s.name}{s.kind === "preset" && <span className="ml-1.5 text-[11px] font-normal text-[var(--text-muted)]">preset</span>}
|
||||
</td>
|
||||
<td className="py-2 pl-3 tabnum">{ev.r.toolsRetired}</td>
|
||||
<td className="py-2 pl-3 tabnum">{Math.round(s.valueFactor * 100)}%</td>
|
||||
<td className="py-2 pl-3 tabnum">{ev.a.planParallel ?? DEFAULT_PARALLEL}/BU</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{money(ev.cash.runRateNet, true)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum font-semibold">{money(ev.cash.horizonNet, true)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{money(ev.cash.npv, true)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{paybackText(ev.cash.paybackMonth)}</td>
|
||||
<td className="py-2 pl-3 text-right tabnum">{monthLabel(ev.plan.startIso, Math.max(0, ev.plan.completion - 1))}</td>
|
||||
<td className="py-2 pl-3 text-right">
|
||||
{s.kind === "custom" && (
|
||||
<span className="flex justify-end gap-1">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(editing === s.id ? null : s.id)}>Edit</button>
|
||||
<button className="btn btn-ghost btn-sm btn-danger" onClick={() => update((d) => {
|
||||
d.scenarios = (d.scenarios ?? []).filter((x) => x.id !== s.id);
|
||||
if (d.presentedScenario === s.id) d.presentedScenario = EXPECTED_ID;
|
||||
})}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editing && a.scenarios?.find((s) => s.id === editing) && (
|
||||
<ScenarioEditor a={a} s={a.scenarios.find((s) => s.id === editing)!} setS={(fn) => setS(editing, fn)} onDone={() => setEditing(null)} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioEditor({ a, s, setS, onDone }: { a: Assessment; s: Scenario; setS: (fn: (s: Scenario) => void) => void; onDone: () => void }) {
|
||||
const excluded = new Set(s.excluded ?? []);
|
||||
const inv = s.investment;
|
||||
return (
|
||||
<div className="fade-up mt-3 rounded-xl border p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="label" htmlFor="sc-name">Name</label>
|
||||
<input id="sc-name" className="field" value={s.name} onChange={(ev) => setS((x) => { x.name = ev.target.value; })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="sc-vf">Value (% of modelled)</label>
|
||||
<input id="sc-vf" className="field tabnum" type="number" min={10} max={200} step={5} value={Math.round(s.valueFactor * 100)}
|
||||
onChange={(ev) => setS((x) => { x.valueFactor = Math.min(2, Math.max(0.1, Number(ev.target.value) / 100)); })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="sc-p">Parallel per BU</label>
|
||||
<select id="sc-p" className="field" value={s.parallel ?? DEFAULT_PARALLEL} onChange={(ev) => setS((x) => { x.parallel = Number(ev.target.value); })}>
|
||||
{[1, 2, 3, 4].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="sc-inv">Subscription override <InternalBadge /></label>
|
||||
<input id="sc-inv" className="field tabnum" type="number" min={0} step={10000} placeholder="Same as base"
|
||||
value={inv?.annual ?? ""}
|
||||
onChange={(ev) => setS((x) => {
|
||||
x.investment = ev.target.value === "" ? undefined : { annual: Math.max(0, Number(ev.target.value)), services: x.investment?.services ?? financeOf(a).investment.services };
|
||||
})} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="label">Tools in scope · {a.candidates.length - excluded.size} of {a.candidates.length}</div>
|
||||
<div className="scroll-thin grid max-h-[180px] gap-0.5 overflow-y-auto sm:grid-cols-2 lg:grid-cols-3">
|
||||
{a.candidates.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 rounded px-1.5 py-1 text-[12px] hover:bg-[var(--surface-2)]">
|
||||
<input type="checkbox" checked={!excluded.has(c.id)}
|
||||
onChange={(ev) => setS((x) => {
|
||||
const set = new Set(x.excluded ?? []);
|
||||
ev.target.checked ? set.delete(c.id) : set.add(c.id);
|
||||
x.excluded = [...set];
|
||||
})} />
|
||||
<span className="truncate">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled"}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end"><button className="btn btn-sm" onClick={onDone}>Done</button></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only comparison for customer deliverables — net figures only. */
|
||||
export function ScenarioComparison({ a, catalog }: { a: Assessment; catalog: Catalog }) {
|
||||
const evals = useMemo(() => allScenarios(a).map((s) => evaluate(a, catalog, s)), [a, catalog]);
|
||||
const presented = a.presentedScenario ?? EXPECTED_ID;
|
||||
return (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-[var(--text-secondary)]">
|
||||
{["Scenario", "Tools", "Net annual benefit", `${a.horizonYears}-year net`, "NPV", "Payback", "Complete"].map((h, i) => (
|
||||
<th key={h} className={`pb-2 font-semibold ${i ? "pl-4 text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{evals.map((ev) => (
|
||||
<tr key={ev.scenario.id} className="border-b border-[var(--grid)] last:border-0" style={ev.scenario.id === presented ? { fontWeight: 600 } : undefined}>
|
||||
<td className="py-1.5">{ev.scenario.name}{ev.scenario.id === presented && " (presented)"}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{ev.r.toolsRetired}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(ev.cash.runRateNet)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(ev.cash.horizonNet)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{money(ev.cash.npv)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{paybackText(ev.cash.paybackMonth)}</td>
|
||||
<td className="whitespace-nowrap py-1.5 pl-4 text-right tabnum">{monthLabel(ev.plan.startIso, Math.max(0, ev.plan.completion - 1))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ cumulative chart */
|
||||
|
||||
/** Cumulative net position month by month, with the breakeven point marked. */
|
||||
export function CumulativeChart({ e }: { e: Evaluation }) {
|
||||
const months = e.cash.months;
|
||||
const cum: number[] = [];
|
||||
months.reduce((s, v, i) => (cum[i] = s + v), 0);
|
||||
const W = 640, H = 220, P = { l: 56, r: 12, t: 12, b: 26 };
|
||||
const lo = Math.min(0, ...cum), hi = Math.max(1, ...cum);
|
||||
const x = (i: number) => P.l + ((W - P.l - P.r) * (i + 1)) / Math.max(1, months.length);
|
||||
const y = (v: number) => P.t + ((H - P.t - P.b) * (hi - v)) / (hi - lo || 1);
|
||||
const path = `M${P.l} ${y(0)} ` + cum.map((v, i) => `L${x(i)} ${y(v)}`).join(" ");
|
||||
const pb = e.cash.paybackMonth;
|
||||
const ticks = [lo, 0, hi].filter((v, i, arr) => arr.indexOf(v) === i);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img"
|
||||
aria-label={`Cumulative net position reaches ${money(cum[cum.length - 1] ?? 0)} after ${e.a.horizonYears} years`}>
|
||||
{ticks.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={P.l} x2={W - P.r} y1={y(v)} y2={y(v)} stroke={v === 0 ? "var(--axis)" : "var(--grid)"} />
|
||||
<text x={P.l - 6} y={y(v) + 4} textAnchor="end" fontSize="10.5" fill="var(--text-muted)">{money(v, true)}</text>
|
||||
</g>
|
||||
))}
|
||||
{Array.from({ length: e.a.horizonYears }, (_, k) => (
|
||||
<text key={k} x={x((k + 1) * 12 - 1)} y={H - 8} textAnchor="end" fontSize="10.5" fill="var(--text-muted)">Year {k + 1}</text>
|
||||
))}
|
||||
<path d={`${path} L${x(cum.length - 1)} ${y(0)} Z`} fill="var(--accent)" fillOpacity=".12" />
|
||||
<path d={path} fill="none" stroke="var(--accent)" strokeWidth="2" />
|
||||
{pb !== null && pb > 0 && pb <= months.length && (
|
||||
<g>
|
||||
<line x1={x(pb - 1)} x2={x(pb - 1)} y1={P.t} y2={H - P.b} stroke="var(--success-text)" strokeDasharray="3 3" />
|
||||
<text x={x(pb - 1) + 4} y={P.t + 10} fontSize="10.5" fill="var(--success-text)">Breakeven month {pb}</text>
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { useId } from "react";
|
||||
|
||||
/**
|
||||
* App mark: rising bars under an upward trend line — consolidation turning into
|
||||
* savings. Fixed colours rather than theme tokens so it reads the same on light
|
||||
* savings. Brand Navy → Azure gradient at 45°, per the brand guide. Fixed colours rather
|
||||
* than theme tokens so it reads the same on light
|
||||
* and dark, and matches the favicon (src/app/icon.svg).
|
||||
*/
|
||||
export default function Logo({ size = 24 }: { size?: number }) {
|
||||
@@ -11,8 +12,8 @@ export default function Logo({ size = 24 }: { size?: number }) {
|
||||
<svg width={size} height={size} viewBox="0 0 32 32" fill="none" aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="32" x2="32" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#2a78d6" />
|
||||
<stop offset="1" stopColor="#1baf7a" />
|
||||
<stop stopColor="#005C8A" />
|
||||
<stop offset="1" stopColor="#0098C7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill={`url(#${id})`} />
|
||||
|
||||
37
src/components/LogoInput.tsx
Normal file
37
src/components/LogoInput.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { MAX_LOGO_BYTES } from "@/lib/images";
|
||||
|
||||
/** Picks a PNG or JPEG and hands it back as a data URL; exports embed it as-is. */
|
||||
export function LogoInput({ label, value, onChange }: { label: string; value: string; onChange: (dataUrl: string) => void }) {
|
||||
const id = useId();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
function pick(file: File | undefined) {
|
||||
setErr("");
|
||||
if (!file) return;
|
||||
if (!["image/png", "image/jpeg"].includes(file.type)) { setErr("Use a PNG or JPEG file."); return; }
|
||||
if (file.size > MAX_LOGO_BYTES) { setErr("That file is over 600 KB — export a smaller version."); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => onChange(String(reader.result));
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={id}>{label}</label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={value} alt="" className="h-10 max-w-[160px] rounded border bg-white object-contain p-1" />
|
||||
) : (
|
||||
<span className="grid h-10 w-24 place-items-center rounded border border-dashed text-[11px] text-[var(--text-muted)]">No logo</span>
|
||||
)}
|
||||
<input id={id} type="file" accept="image/png,image/jpeg" className="text-[12px]" onChange={(e) => { pick(e.target.files?.[0]); e.target.value = ""; }} />
|
||||
{value && <button className="btn btn-ghost btn-sm" onClick={() => onChange("")}>Remove</button>}
|
||||
</div>
|
||||
{err && <p className="mt-1 text-[12px]" style={{ color: "var(--critical)" }}>{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import ThemeToggle from "./ThemeToggle";
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Assessments" },
|
||||
{ href: "/knowledge-base", label: "Knowledge base" },
|
||||
{ href: "/portfolio", label: "Portfolio" },
|
||||
{ href: "/catalog", label: "Product catalog" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
|
||||
80
src/components/Overlap.tsx
Normal file
80
src/components/Overlap.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Catalog } from "@/lib/types";
|
||||
import type { Rollup } from "@/lib/scoring";
|
||||
import { money } from "@/lib/format";
|
||||
import { ChartFrame, Empty } from "@/components/charts";
|
||||
|
||||
/**
|
||||
* Where the customer pays for the same capability more than once. Rows are
|
||||
* capability categories, shaded by how many incumbent tools overlap in each.
|
||||
*/
|
||||
export function OverlapMap({ r, catalog }: { r: Rollup; catalog: Catalog }) {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const rows = [...r.byDomain].sort((x, y) => y.count - x.count || y.spend - x.spend);
|
||||
const maxSpend = Math.max(1, ...rows.map((d) => d.spend));
|
||||
const redundant = rows.filter((d) => d.count > 1);
|
||||
const tone = (n: number) => (n >= 4 ? 0.9 : n === 3 ? 0.65 : n === 2 ? 0.4 : 0.12);
|
||||
|
||||
return (
|
||||
<ChartFrame
|
||||
title="Overlap heat map"
|
||||
subtitle={redundant.length
|
||||
? `${redundant.length} categor${redundant.length === 1 ? "y runs" : "ies run"} more than one tool — ${money(redundant.reduce((s, d) => s + d.spend, 0), true)} of cost where the customer pays twice.`
|
||||
: "No category runs more than one incumbent tool."}
|
||||
table={{
|
||||
headers: ["Capability", "Tools", "Vendors", "Current cost", "Net annual benefit"],
|
||||
rows: rows.map((d) => [d.name, d.count, d.vendors, money(d.spend), money(d.savings)]),
|
||||
}}
|
||||
>
|
||||
{rows.length === 0 ? <Empty>Add incumbent tools to see overlap.</Empty> : (
|
||||
<ul className="divide-y divide-[var(--grid)]">
|
||||
{rows.map((d) => {
|
||||
const tools = r.included.filter((c) => c.domain === d.domainId);
|
||||
const isOpen = open === d.domainId;
|
||||
return (
|
||||
<li key={d.domainId}>
|
||||
<button className="grid w-full grid-cols-[minmax(0,1fr)_64px_minmax(0,1.2fr)_88px] items-center gap-3 py-2 text-left hover:bg-[var(--surface-2)]"
|
||||
aria-expanded={isOpen} onClick={() => setOpen(isOpen ? null : d.domainId)}>
|
||||
<span className="truncate pl-1 text-[12.5px] font-medium">{d.name}</span>
|
||||
<span className="rounded-md py-1 text-center tabnum text-[12.5px] font-semibold"
|
||||
style={{
|
||||
background: `color-mix(in oklab, var(--accent) ${Math.round(tone(d.count) * 100)}%, transparent)`,
|
||||
color: d.count >= 3 ? "var(--accent-ink)" : "var(--text-primary)",
|
||||
}}
|
||||
title={`${d.count} tools from ${d.vendors} vendor${d.vendors === 1 ? "" : "s"}`}>
|
||||
{d.count}
|
||||
</span>
|
||||
<span className="h-2 overflow-hidden rounded-full bg-[var(--grid)]">
|
||||
<span className="block h-full rounded-full bg-[var(--series-1)]" style={{ width: `${Math.max(1.5, (d.spend / maxSpend) * 100)}%` }} />
|
||||
</span>
|
||||
<span className="pr-1 text-right tabnum text-[12.5px]">{money(d.spend, true)}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className="fade-up mb-2 ml-1 flex flex-wrap gap-1.5">
|
||||
{tools.map((c) => (
|
||||
<li key={c.id} className="chip">
|
||||
{`${c.incumbentVendor} ${c.incumbentProduct}`.trim()} · {money(r.metrics.get(c.id)!.tco, true)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-3 text-[11px] text-[var(--text-muted)]">
|
||||
<span>Tools per category</span>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<span key={n} className="flex items-center gap-1">
|
||||
<span className="h-3 w-4 rounded-sm" style={{ background: `color-mix(in oklab, var(--accent) ${Math.round(tone(n) * 100)}%, transparent)` }} />
|
||||
{n === 4 ? "4+" : n}
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto">Bar = current cost</span>
|
||||
</div>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
64
src/components/Shelfware.tsx
Normal file
64
src/components/Shelfware.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import { shelfware } from "@/lib/shelfware";
|
||||
import { num } from "@/lib/format";
|
||||
import { InternalBadge } from "@/components/Finance";
|
||||
|
||||
/**
|
||||
* Entitled-but-undeployed products from the dossier. Linked ones already
|
||||
* cover a displacement, so that line needs no new subscription; the rest are
|
||||
* adoption upside. Internal: the dossier never reaches the customer.
|
||||
*/
|
||||
export function ShelfwarePanel({ a, catalog }: { a: Assessment; catalog: Catalog }) {
|
||||
const items = shelfware(a, catalog);
|
||||
if (!items.length) return null;
|
||||
const label = (id: string) => {
|
||||
const c = a.candidates.find((x) => x.id === id);
|
||||
return c ? `${c.incumbentVendor} ${c.incumbentProduct}`.trim() : id;
|
||||
};
|
||||
const linked = items.filter((s) => s.linked.length);
|
||||
const upside = items.filter((s) => !s.linked.length);
|
||||
|
||||
return (
|
||||
<section className="card mb-5 p-5">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Shelfware and expansion</h3>
|
||||
<InternalBadge />
|
||||
</div>
|
||||
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
|
||||
From the account dossier: products the customer already owns but has not fully deployed. Displacements that land on
|
||||
one of these need no new subscription.
|
||||
</p>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="label">Already entitled — covers {linked.reduce((s, x) => s + x.linked.length, 0)} displacement{linked.reduce((s, x) => s + x.linked.length, 0) === 1 ? "" : "s"}</div>
|
||||
{linked.length === 0 ? <p className="text-[12.5px] text-[var(--text-muted)]">None of the unused entitlements match a mapped replacement.</p> : (
|
||||
<ul className="space-y-1.5">
|
||||
{linked.map((s) => (
|
||||
<li key={s.product} className="rounded-lg border px-3 py-2">
|
||||
<div className="flex items-baseline gap-2 text-[12.5px]">
|
||||
<span className="font-medium">{s.match!.name}</span>
|
||||
<span className="ml-auto tabnum text-[var(--text-muted)]">{num(s.unused)} unused of {num(s.entitled)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-secondary)]">Replaces {s.linked.map(label).join(", ")}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Adoption upside — owned, not yet in the plan</div>
|
||||
<ul className="space-y-1">
|
||||
{upside.map((s) => (
|
||||
<li key={s.product} className="flex items-baseline gap-2 rounded-lg bg-[var(--surface-2)] px-3 py-1.5 text-[12.5px]">
|
||||
<span className="font-medium">{s.match?.name ?? s.product}</span>
|
||||
<span className="ml-auto tabnum text-[var(--text-muted)]">{num(s.unused)} unused of {num(s.entitled)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
162
src/components/Snapshots.tsx
Normal file
162
src/components/Snapshots.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog, Snapshot, SnapshotMeta } from "@/lib/types";
|
||||
import { evaluate, findScenario } from "@/lib/finance";
|
||||
import { candidateLabel } from "@/lib/kb";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
const TRIGGER: Record<SnapshotMeta["trigger"], string> = {
|
||||
manual: "Saved", word: "Word export", deck: "Deck export", print: "Printed", restore: "Before restore",
|
||||
};
|
||||
|
||||
/** Version history: named snapshots, automatic "as presented" copies, compare and restore. */
|
||||
export default function SnapshotPanel({
|
||||
a, catalog, onRestored, onClose,
|
||||
}: {
|
||||
a: Assessment; catalog: Catalog; onRestored: (next: Assessment) => void; onClose: () => void;
|
||||
}) {
|
||||
const base = `/api/assessments/${a.id}/snapshots`;
|
||||
const [list, setList] = useState<SnapshotMeta[] | null>(null);
|
||||
const [label, setLabel] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [compare, setCompare] = useState<Snapshot | null>(null);
|
||||
|
||||
const load = () => getJson<SnapshotMeta[]>(base).then(setList).catch((e) => setErr(errorMessage(e)));
|
||||
// Loads once per assessment; `base` changes only with the id.
|
||||
useEffect(() => { load(); }, [base]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setErr("");
|
||||
try { await sendJson(base, "POST", { label: label || `Snapshot ${new Date().toLocaleDateString()}` }); setLabel(""); await load(); }
|
||||
catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function restore(s: SnapshotMeta) {
|
||||
if (!confirm(`Restore “${s.label}”? The current version is saved as a snapshot first, so this can be undone.`)) return;
|
||||
setBusy(true); setErr("");
|
||||
try { onRestored(await sendJson<Assessment>(`${base}/${s.id}`, "POST", { action: "restore" })); await load(); setCompare(null); }
|
||||
catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"
|
||||
role="dialog" aria-modal="true" aria-label="Versions"
|
||||
onClick={(ev) => { if (ev.target === ev.currentTarget) onClose(); }}>
|
||||
<div className="card fade-up my-8 w-full max-w-[880px] overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b px-5 py-3.5">
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Versions</h2>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
A snapshot is taken automatically whenever a Word file or deck is exported or the report is printed, so you can
|
||||
always reproduce what a customer saw.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="field max-w-[360px]" placeholder="Name this version, e.g. “Sent to CFO”" value={label}
|
||||
onChange={(ev) => setLabel(ev.target.value)} onKeyDown={(ev) => { if (ev.key === "Enter") save(); }} />
|
||||
<button className="btn btn-primary" onClick={save} disabled={busy}>Save snapshot</button>
|
||||
</div>
|
||||
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
|
||||
|
||||
{list === null ? <p className="text-[13px] text-[var(--text-muted)]">Loading…</p> : list.length === 0 ? (
|
||||
<p className="text-[13px] text-[var(--text-muted)]">No versions yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border">
|
||||
{list.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3.5 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-medium">{s.label}</div>
|
||||
<div className="text-[11.5px] text-[var(--text-muted)]">
|
||||
{TRIGGER[s.trigger]} · {new Date(s.createdAt).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto tabnum text-right text-[12px] text-[var(--text-secondary)]">
|
||||
{money(s.headline.netAnnual, true)}/yr · {money(s.headline.horizonNet, true)} net · {s.headline.tools} tools
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="btn btn-sm" onClick={() => getJson<Snapshot>(`${base}/${s.id}`).then(setCompare).catch((e) => setErr(errorMessage(e)))}>Compare</button>
|
||||
<button className="btn btn-sm" onClick={() => restore(s)} disabled={busy}>Restore</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{compare && <Compare then={compare} now={a} catalog={catalog} onClose={() => setCompare(null)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Compare({ then, now, catalog, onClose }: { then: Snapshot; now: Assessment; catalog: Catalog; onClose: () => void }) {
|
||||
const d = useMemo(() => {
|
||||
const ev = (x: Assessment) => evaluate(x, catalog, findScenario(x, x.presentedScenario));
|
||||
const a = ev(then.assessment), b = ev(now);
|
||||
const key = (c: Assessment["candidates"][number]) => c.id;
|
||||
const before = new Map(then.assessment.candidates.map((c) => [key(c), c]));
|
||||
const after = new Map(now.candidates.map((c) => [key(c), c]));
|
||||
const added = now.candidates.filter((c) => !before.has(key(c)));
|
||||
const removed = then.assessment.candidates.filter((c) => !after.has(key(c)));
|
||||
const changed = now.candidates.flatMap((c) => {
|
||||
const o = before.get(key(c));
|
||||
if (!o) return [];
|
||||
const diffs: string[] = [];
|
||||
if (o.annualSpend !== c.annualSpend) diffs.push(`spend ${money(o.annualSpend, true)} → ${money(c.annualSpend, true)}`);
|
||||
if (o.included !== c.included) diffs.push(c.included ? "included" : "excluded");
|
||||
if (o.productIds.join() !== c.productIds.join()) diffs.push("replacement changed");
|
||||
if (o.renewalDate !== c.renewalDate) diffs.push(`renewal ${o.renewalDate ? dateLabel(o.renewalDate) : "—"} → ${c.renewalDate ? dateLabel(c.renewalDate) : "—"}`);
|
||||
if (o.savingsPct !== c.savingsPct) diffs.push(`savings ${o.savingsPct}% → ${c.savingsPct}%`);
|
||||
if (o.migrationCost !== c.migrationCost) diffs.push(`migration ${money(o.migrationCost, true)} → ${money(c.migrationCost, true)}`);
|
||||
return diffs.length ? [{ c, diffs }] : [];
|
||||
});
|
||||
return {
|
||||
rows: [
|
||||
["Net annual benefit", a.cash.runRateNet, b.cash.runRateNet],
|
||||
[`${now.horizonYears}-year net`, a.cash.horizonNet, b.cash.horizonNet],
|
||||
["NPV", a.cash.npv, b.cash.npv],
|
||||
["Current cost (TCO)", a.r.addressableTco, b.r.addressableTco],
|
||||
] as [string, number, number][],
|
||||
tools: [a.r.toolsRetired, b.r.toolsRetired],
|
||||
added, removed, changed,
|
||||
};
|
||||
}, [then, now, catalog]);
|
||||
|
||||
return (
|
||||
<div className="fade-up rounded-xl border p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="text-[13.5px] font-semibold">“{then.label}” compared with now</h3>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<table className="mb-3 w-full text-[12.5px]">
|
||||
<thead><tr className="border-b text-[var(--text-secondary)]"><th className="pb-1.5 text-left font-semibold" /><th className="pb-1.5 text-right font-semibold">Then</th><th className="pb-1.5 text-right font-semibold">Now</th><th className="pb-1.5 text-right font-semibold">Change</th></tr></thead>
|
||||
<tbody>
|
||||
{d.rows.map(([k, x, y]) => (
|
||||
<tr key={k} className="border-b border-[var(--grid)]">
|
||||
<td className="py-1.5">{k}</td>
|
||||
<td className="py-1.5 text-right tabnum">{money(x)}</td>
|
||||
<td className="py-1.5 text-right tabnum">{money(y)}</td>
|
||||
<td className="py-1.5 text-right tabnum font-semibold" style={{ color: y - x > 0 ? "var(--success-text)" : y - x < 0 ? "var(--critical)" : undefined }}>
|
||||
{y - x === 0 ? "—" : `${y - x > 0 ? "+" : "−"}${money(Math.abs(y - x))}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr><td className="py-1.5">Tools</td><td className="py-1.5 text-right tabnum">{d.tools[0]}</td><td className="py-1.5 text-right tabnum">{d.tools[1]}</td><td className="py-1.5 text-right tabnum">{d.tools[1] - d.tools[0] || "—"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{d.added.length + d.removed.length + d.changed.length === 0 ? (
|
||||
<p className="text-[12.5px] text-[var(--text-muted)]">No tool lines changed.</p>
|
||||
) : (
|
||||
<ul className="space-y-1 text-[12.5px]">
|
||||
{d.added.map((c) => <li key={`a-${c.id}`}><span style={{ color: "var(--success-text)" }}>+ Added</span> {candidateLabel(c) || "Untitled"}</li>)}
|
||||
{d.removed.map((c) => <li key={`r-${c.id}`}><span style={{ color: "var(--critical)" }}>− Removed</span> {candidateLabel(c) || "Untitled"}</li>)}
|
||||
{d.changed.map(({ c, diffs }) => <li key={`c-${c.id}`}><span className="text-[var(--accent)]">~ Changed</span> {candidateLabel(c)}: {diffs.join(", ")}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/lib/brand.ts
Normal file
27
src/lib/brand.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* VMware by Broadcom palette (Brand Guidelines, "Colors & Design Components").
|
||||
* Light-theme values are the AA-safe ones for text on white; dark-theme values
|
||||
* are the guide's adjusted set for dark backgrounds.
|
||||
*/
|
||||
export const BRAND = {
|
||||
darkBlue: "#1B1D36",
|
||||
navy: "#005C8A",
|
||||
lightBlue: "#007DA3",
|
||||
azure: "#0098C7",
|
||||
aqua: "#007B8C",
|
||||
green: "#61A60E",
|
||||
greenAA: "#23800A",
|
||||
purple: "#6C4B94",
|
||||
} as const;
|
||||
|
||||
/** Swatches offered wherever a colour is picked (accent, business units). */
|
||||
export const BRAND_SWATCHES: { name: string; hex: string }[] = [
|
||||
{ name: "Navy", hex: BRAND.navy },
|
||||
{ name: "Light Blue", hex: BRAND.lightBlue },
|
||||
{ name: "Azure", hex: BRAND.azure },
|
||||
{ name: "Aqua", hex: BRAND.aqua },
|
||||
{ name: "Green", hex: BRAND.greenAA },
|
||||
{ name: "Lime", hex: BRAND.green },
|
||||
{ name: "Purple", hex: BRAND.purple },
|
||||
{ name: "Dark Blue", hex: BRAND.darkBlue },
|
||||
];
|
||||
60
src/lib/deliverable.ts
Normal file
60
src/lib/deliverable.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Assessment, Catalog } from "./types";
|
||||
import { type Evaluation, evaluate, findScenario } from "./finance";
|
||||
import { money } from "./format";
|
||||
import { monthLabel } from "./timeline";
|
||||
|
||||
/**
|
||||
* Shared content for customer deliverables (Word, deck). Takes the redacted
|
||||
* assessment, so nothing internal can reach a file, and presents the scenario
|
||||
* the seller chose. Figures are net: our investment is never itemized.
|
||||
*/
|
||||
export function deliverable(a: Assessment, catalog: Catalog) {
|
||||
const e = evaluate(a, catalog, findScenario(a, a.presentedScenario));
|
||||
return { e, summary: summaryText(e), nextSteps: nextSteps(e) };
|
||||
}
|
||||
|
||||
export function summaryText(e: Evaluation) {
|
||||
const { a, r, cash } = e;
|
||||
if (a.executiveSummary.trim()) return a.executiveSummary.trim();
|
||||
// Only claim a smaller estate when the mapping actually produces one; otherwise
|
||||
// the consolidation story is vendors, not tool count.
|
||||
const footprint = r.toolsAfter < r.toolsRetired
|
||||
? `reduces the estate from ${r.toolsRetired} tools to ${r.toolsAfter}`
|
||||
: `brings ${r.vendorsRetired} vendor relationships down to ${r.vendorsAfter}`;
|
||||
return (
|
||||
`Consolidating ${r.toolsRetired} point tools from ${r.vendorsRetired} vendors onto an integrated platform removes ` +
|
||||
`${money(r.addressableTco)} of annual cost of ownership and ${footprint}. ` +
|
||||
`Once every migration is complete the net benefit is ${money(cash.runRateNet)} a year. Across the ${a.horizonYears}-year plan, ` +
|
||||
`with savings arriving as each tool is retired, the net benefit is ${money(cash.horizonNet)} (NPV ${money(cash.npv)})` +
|
||||
`${cash.paybackMonth ? `, paying back in month ${cash.paybackMonth}` : ""}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** Concrete next steps drawn from the plan, soonest first. */
|
||||
export function nextSteps(e: Evaluation): string[] {
|
||||
const { plan, r } = e;
|
||||
const L = (n: number) => monthLabel(plan.startIso, n);
|
||||
const steps: string[] = [];
|
||||
|
||||
const first = plan.items.filter((i) => i.start < 3);
|
||||
if (first.length) {
|
||||
steps.push(`Start the first ${first.length} migration${first.length === 1 ? "" : "s"} by ${L(2)}: ${first.slice(0, 5).map((i) => i.label).join(", ")}${first.length > 5 ? " and more" : ""}.`);
|
||||
}
|
||||
const bridges = plan.items.filter((i) => i.bridge);
|
||||
if (bridges.length) {
|
||||
steps.push(`Negotiate short extensions for ${bridges.map((i) => `${i.label} (ends ${L(i.contractEnd!)})`).join(", ")} — the contract ends before migration can finish.`);
|
||||
}
|
||||
const soon = plan.items.filter((i) => i.contractEnd !== null && !i.bridge && i.contractEnd < 12);
|
||||
if (soon.length) {
|
||||
steps.push(`Give notice on contracts ending in the next 12 months: ${soon.map((i) => `${i.label} (${L(i.contractEnd!)})`).join(", ")}.`);
|
||||
}
|
||||
const noSpend = r.included.filter((c) => c.annualSpend <= 0).length;
|
||||
if (noSpend) {
|
||||
steps.push(`Confirm current spend for ${noSpend} tool${noSpend === 1 ? "" : "s"} with no cost captured yet — they are valued at zero today.`);
|
||||
}
|
||||
if (r.unmappedCount) {
|
||||
steps.push(`Agree a replacement for ${r.unmappedCount} unmapped tool${r.unmappedCount === 1 ? "" : "s"} so they can join the plan.`);
|
||||
}
|
||||
steps.push("Appoint an executive sponsor and a program lead, and confirm the sequence in a joint planning workshop.");
|
||||
return steps;
|
||||
}
|
||||
153
src/lib/finance.ts
Normal file
153
src/lib/finance.ts
Normal 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
27
src/lib/images.ts
Normal 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;
|
||||
}
|
||||
139
src/lib/kb.ts
139
src/lib/kb.ts
@@ -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
77
src/lib/portfolio.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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 } } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
77
src/lib/shelfware.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]) => {
|
||||
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">;
|
||||
};
|
||||
|
||||
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;
|
||||
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 ?? "",
|
||||
workstream: bu?.id ?? "other",
|
||||
wave: m.wave,
|
||||
contractEnd: ce,
|
||||
annualSavings: m.annualSavings,
|
||||
migrationCost: c.migrationCost,
|
||||
duration: DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5,
|
||||
grossRemoved: m.grossRemoved,
|
||||
migrationCost: m.oneTime,
|
||||
edited: !!(c.plan?.start || c.plan?.duration || c.plan?.after?.length),
|
||||
after,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// 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 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 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;
|
||||
}
|
||||
|
||||
items.sort((x, y) => x.start - y.start || x.end - y.end || y.annualSavings - x.annualSavings);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
105
src/lib/types.ts
105
src/lib/types.ts
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user