Initial commit: Converge portfolio rationalization app
Next.js app for mapping a customer's competitive tool landscape onto our portfolio and building the consolidation business case: assessment workbench, customer report and Word export, product catalog, competitive knowledge base, dossier import, shareable landscape questionnaires with a review inbox, tool-footprint and business-unit value analysis, and an implementation timeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
.run/
|
||||
.test-build/
|
||||
*.tmp
|
||||
1092
data/assessments/guidewell.json
Normal file
1092
data/assessments/guidewell.json
Normal file
File diff suppressed because it is too large
Load Diff
418
data/assessments/macy-s.json
Normal file
418
data/assessments/macy-s.json
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"id": "macy-s",
|
||||
"version": 1,
|
||||
"customerName": "Macy's",
|
||||
"industry": "Retailing",
|
||||
"preparedBy": "",
|
||||
"preparedFor": "",
|
||||
"opportunityRef": "",
|
||||
"executiveSummary": "",
|
||||
"horizonYears": 3,
|
||||
"weights": {
|
||||
"capability": 35,
|
||||
"switchEase": 25,
|
||||
"timeToRoi": 25,
|
||||
"spend": 15
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"id": "c-muea0k0k-g9e0z",
|
||||
"incumbentVendor": "F5",
|
||||
"incumbentProduct": "BIG-IP LTM",
|
||||
"domain": "adc",
|
||||
"productIds": [
|
||||
"ans-avi"
|
||||
],
|
||||
"annualSpend": 0,
|
||||
"units": 0,
|
||||
"unitLabel": "",
|
||||
"renewalDate": "",
|
||||
"capability": 5,
|
||||
"switchEase": 4,
|
||||
"timeToRoiMonths": 6,
|
||||
"savingsPct": 55,
|
||||
"migrationCost": 220000,
|
||||
"strategicValue": 3,
|
||||
"risks": "iRules migration needs an audit; 6 legacy apps use custom TCL.",
|
||||
"notes": "From account dossier — Load Balancer Vendor: F5",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-muea0k0k-cm854",
|
||||
"incumbentVendor": "Cisco",
|
||||
"incumbentProduct": "Secure Firewall",
|
||||
"domain": "network-sec",
|
||||
"productIds": [
|
||||
"ans-vdefend-gw"
|
||||
],
|
||||
"annualSpend": 0,
|
||||
"units": 0,
|
||||
"unitLabel": "",
|
||||
"renewalDate": "",
|
||||
"capability": 4,
|
||||
"switchEase": 3,
|
||||
"timeToRoiMonths": 12,
|
||||
"savingsPct": 50,
|
||||
"migrationCost": 0,
|
||||
"strategicValue": 3,
|
||||
"risks": "",
|
||||
"notes": "From account dossier — Firewall Vendor: Cisco",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-muea0k0k-fu2pl",
|
||||
"incumbentVendor": "Cisco",
|
||||
"incumbentProduct": "ACI",
|
||||
"domain": "networking",
|
||||
"productIds": [
|
||||
"ans-nsx"
|
||||
],
|
||||
"annualSpend": 0,
|
||||
"units": 0,
|
||||
"unitLabel": "",
|
||||
"renewalDate": "",
|
||||
"capability": 4,
|
||||
"switchEase": 3,
|
||||
"timeToRoiMonths": 12,
|
||||
"savingsPct": 50,
|
||||
"migrationCost": 0,
|
||||
"strategicValue": 3,
|
||||
"risks": "",
|
||||
"notes": "From account dossier — Network Vendor: Cisco",
|
||||
"included": true
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-09-23T15:46:26.506Z",
|
||||
"updatedAt": "2026-09-23T15:47:09.312Z",
|
||||
"accountIntel": {
|
||||
"sourceUrl": "https://docs.google.com/document/d/1BvYwjREVc5zw2EgIKdFmvQgNAHoz0FR68ver6wgIMTA/edit?usp=drivesdk",
|
||||
"sourceTitle": "MACYS — Strategic Account Dossier",
|
||||
"importedAt": "2026-09-23T15:46:40.492Z",
|
||||
"company": "MACYS",
|
||||
"industry": "Retailing",
|
||||
"classification": "Tier 1 / Strategic",
|
||||
"accountStatus": "Dormant",
|
||||
"sentiment": "Negative (Legal)",
|
||||
"renewalTimeframe": "2028 Q 3",
|
||||
"renewalRisk": "2",
|
||||
"healthScore": "n/a/100",
|
||||
"intelligenceScore": "89/100",
|
||||
"tamValueScore": "39/100",
|
||||
"vcf": [
|
||||
{
|
||||
"label": "VCF Cores",
|
||||
"value": "27168 → 27168 → 27168 → 27168 → 27168 → 27168 → 27168 → 27168 → 27168 → 27168 → 16608 → 16608 → 16608"
|
||||
},
|
||||
{
|
||||
"label": "VCF Version",
|
||||
"value": "VCF 9.1, vSphere 8.x, vSphere 7.x and below"
|
||||
},
|
||||
{
|
||||
"label": "VCF Cores Entitled",
|
||||
"value": "16608"
|
||||
},
|
||||
{
|
||||
"label": "Cores by Version",
|
||||
"value": "VCF 9.1: 128, vSphere 7.x and below: 3000, vSphere 8.x: 12544"
|
||||
},
|
||||
{
|
||||
"label": "Deployment Model",
|
||||
"value": "Mix"
|
||||
},
|
||||
{
|
||||
"label": "VCF Path",
|
||||
"value": "Brownfield"
|
||||
},
|
||||
{
|
||||
"label": "Adoption Stage",
|
||||
"value": "Partial VCF Adoption"
|
||||
},
|
||||
{
|
||||
"label": "VCF 9 Plans",
|
||||
"value": "VCF 9.1.x"
|
||||
},
|
||||
{
|
||||
"label": "VCF 9 Blockers",
|
||||
"value": "Blocked - Hardware"
|
||||
}
|
||||
],
|
||||
"infrastructure": [
|
||||
{
|
||||
"label": "Compute Vendor",
|
||||
"value": "HPE"
|
||||
},
|
||||
{
|
||||
"label": "Storage Platform",
|
||||
"value": "Everpure (Pure)"
|
||||
},
|
||||
{
|
||||
"label": "Network Vendor",
|
||||
"value": "Cisco"
|
||||
},
|
||||
{
|
||||
"label": "Firewall Vendor",
|
||||
"value": "Cisco"
|
||||
},
|
||||
{
|
||||
"label": "Load Balancer Vendor",
|
||||
"value": "F5"
|
||||
}
|
||||
],
|
||||
"cloud": [
|
||||
{
|
||||
"label": "Cloud Strategy",
|
||||
"value": "Public Cloud First"
|
||||
},
|
||||
{
|
||||
"label": "Primary Hyperscaler",
|
||||
"value": "GCP"
|
||||
},
|
||||
{
|
||||
"label": "Workload Distribution",
|
||||
"value": "65% Public Cloud, 25% Private Cloud, 10% Mainframe"
|
||||
}
|
||||
],
|
||||
"applications": [],
|
||||
"consumption": [
|
||||
{
|
||||
"product": "VSPHERE",
|
||||
"entitled": 16608,
|
||||
"deployed": 15544,
|
||||
"adoption": "High"
|
||||
},
|
||||
{
|
||||
"product": "VSAN",
|
||||
"entitled": 16608,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "NSX",
|
||||
"entitled": 16608,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "VCF OPERATIONS",
|
||||
"entitled": 16608,
|
||||
"deployed": 15544,
|
||||
"adoption": "Partial"
|
||||
},
|
||||
{
|
||||
"product": "VCF SDDC",
|
||||
"entitled": 16608,
|
||||
"deployed": 192,
|
||||
"adoption": "Partial"
|
||||
},
|
||||
{
|
||||
"product": "VCF AUTOMATION",
|
||||
"entitled": 16608,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "VKS",
|
||||
"entitled": 16608,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "CLARITY",
|
||||
"entitled": 4888,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "RALLY",
|
||||
"entitled": 4800,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "CONNECTALL",
|
||||
"entitled": 0,
|
||||
"deployed": 800,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "MSD MF OBSERVABILITY",
|
||||
"entitled": 47900,
|
||||
"deployed": 0,
|
||||
"adoption": "Shelfware"
|
||||
},
|
||||
{
|
||||
"product": "MSD DATA MGMT",
|
||||
"entitled": 19960,
|
||||
"deployed": 0,
|
||||
"adoption": "High"
|
||||
},
|
||||
{
|
||||
"product": "MSD DEVOPS APP DEV",
|
||||
"entitled": 14970,
|
||||
"deployed": 0,
|
||||
"adoption": "Partial"
|
||||
},
|
||||
{
|
||||
"product": "MF INFRASTRUCTURE PRODUCTS",
|
||||
"entitled": 12475,
|
||||
"deployed": 0,
|
||||
"adoption": "High"
|
||||
}
|
||||
],
|
||||
"deploymentRate": "14% (32080 / 221249)",
|
||||
"narratives": [
|
||||
{
|
||||
"heading": "Security Posture",
|
||||
"body": "Subject to PCI-DSS for retail payments. SOX compliance. Macy's experienced a payment card breach in 2018 (online account compromise). The shift to GCP cloud-native architecture may partially be driven by security modernization post-breach. Cisco firewalls."
|
||||
},
|
||||
{
|
||||
"heading": "App Count Discovered",
|
||||
"body": "2"
|
||||
},
|
||||
{
|
||||
"heading": "New Apps (not in TD)",
|
||||
"body": "[WEB] In-House Warehouse Management Software (GCP-hosted, replacing legacy WMS) | Oracle E-Business Suite (EBS - enterprise platform, targeted in Cl0p breach)"
|
||||
},
|
||||
{
|
||||
"heading": "Org Contacts Found",
|
||||
"body": "0"
|
||||
},
|
||||
{
|
||||
"heading": "Infra Confirmations",
|
||||
"body": "GCP primary hyperscaler confirmed. HPE compute confirmed. Pure Storage confirmed. Cisco networking and firewalls confirmed. F5 confirmed."
|
||||
},
|
||||
{
|
||||
"heading": "Infra Contradictions",
|
||||
"body": "VCF 'Not Deployed' status with 27K cores on vSphere-only suggests a bare vSphere environment that never adopted the full VCF stack. The GCP-first strategy at 65% public cloud may mean on-prem is actively shrinking. Store closures (150 by 2026) will further reduce on-prem footprint."
|
||||
},
|
||||
{
|
||||
"heading": "Data Validation Notes",
|
||||
"body": "HIGHEST RISK ACCOUNT IN TIER 1. Negative sentiment at both exec and tech sponsor level + Legal designation + VCF Not Deployed + GCP-first migration = active churn risk. The 27K core on-prem footprint is likely in maintenance mode."
|
||||
},
|
||||
{
|
||||
"heading": "Tech Debt Signals",
|
||||
"body": "Legacy department store systems. POS modernization ongoing with cloud-native architecture. Store closures reducing legacy infrastructure. Mainframe (10%) for financial and merchandising systems. [WEB] Oracle E-Business Suite (EBS) confirmed in technology stack — the specific platform targeted in Nov 2025 Cl0p breach. Legacy systems being migrated to GCP. In-house WMS development suggests replacing older warehouse systems."
|
||||
},
|
||||
{
|
||||
"heading": "Cloud Strategy Assessment",
|
||||
"body": "GCP-dominant with 65% public cloud and growing. Anthos for hybrid management. Google Cloud Build for CI/CD. OSS K8s for non-Google container workloads. Macy's has been a Google Cloud retail showcase customer. The 25% private cloud is likely shrinking as stores close and workloads migrate."
|
||||
},
|
||||
{
|
||||
"heading": "Cloud Strategic Partnerships",
|
||||
"body": "Google Cloud strategic partnership — Macy's is a GCP retail reference customer. Google Cloud presentations by Macy's technology leaders."
|
||||
},
|
||||
{
|
||||
"heading": "Cloud Repatriation Signals",
|
||||
"body": "No repatriation signals — opposite trend. Macy's is actively migrating TO GCP."
|
||||
},
|
||||
{
|
||||
"heading": "Cloud Sustainability",
|
||||
"body": "Cloud migration supports sustainability by reducing on-prem data center footprint. Store closures further reduce energy consumption."
|
||||
},
|
||||
{
|
||||
"heading": "DevOps Platform Assessment",
|
||||
"body": "Google Cloud Build for GCP-native development. Ansible for configuration management. OpenShift Pipelines despite no OpenShift in container platforms — may be legacy or being phased out."
|
||||
},
|
||||
{
|
||||
"heading": "AI Initiatives",
|
||||
"body": "Macy's uses AI for personalized marketing, demand forecasting, inventory optimization, dynamic pricing, and customer analytics. AI-powered styling recommendations in stores and online. Computer vision for store analytics. Heavy use of Google Cloud AI/ML services. [WEB] AI and automation central to retail revival strategy. Google Cloud partnership since 2015 for analytics, warehouse optimization, retail search. In-house developed warehouse management software on GCP. BigQuery for analytics. Retail Search integration. Moving more workloads to cloud for cost reduction."
|
||||
},
|
||||
{
|
||||
"heading": "AI Infrastructure Assessment",
|
||||
"body": "GCP (Vertex AI, BigQuery ML) for AI/ML. Limited on-prem AI given cloud-first strategy. Edge AI at remaining stores for customer experience."
|
||||
},
|
||||
{
|
||||
"heading": "Competitive Threats",
|
||||
"body": "CRITICAL: Macy's is actively migrating to GCP. VCF Not Deployed means they never bought into the full stack. Negative sentiment at all levels suggests relationship damage (possibly Broadcom pricing changes). Store closures will mechanically reduce the on-prem footprint. Anthos positions Google as the complete alternative."
|
||||
},
|
||||
{
|
||||
"heading": "Flight Risk Hiring Signals",
|
||||
"body": "[{\"platform\":\"GCP\",\"signal_type\":\"Platform Expansion\",\"evidence\":\"Heavy Google Cloud hiring — Macys is GCP showcase\",\"risk\":\"Critical\"},{\"platform\":\"VMware\",\"signal_type\":\"Declining\",\"evidence\":\"Minimal VMware-specific hiring\",\"risk\":\"Critical\"}]"
|
||||
},
|
||||
{
|
||||
"heading": "Flight Risk Migration Tracker",
|
||||
"body": "[{\"from\":\"On-prem/vSphere\",\"to\":\"GCP\",\"evidence\":\"65% cloud, GCP showcase customer, Negative sentiment\",\"confidence\":\"High\"}]"
|
||||
},
|
||||
{
|
||||
"heading": "Flight Risk Product Details",
|
||||
"body": "[{\"product\":\"vSphere\",\"risk\":\"Critical\",\"competitor\":\"GCP Compute Engine\",\"evidence\":\"VCF Not Deployed, 65% cloud, active migration\"},{\"product\":\"vSAN\",\"risk\":\"Critical\",\"competitor\":\"GCP Persistent Disk, Pure Storage\",\"evidence\":\"Pure Storage + GCP displacing any VMware storage\"},{\"product\":\"NSX\",\"risk\":\"Critical\",\"competitor\":\"GCP VPC, Cisco ACI\",\"evidence\":\"Cisco networking and firewalls, GCP networking growing\"},{\"product\":\"Tanzu\",\"risk\":\"Critical\",\"competitor\":\"Anthos/OSS K8s\",\"evidence\":\"Anthos and OSS K8s — no Tanzu presence\"}]"
|
||||
},
|
||||
{
|
||||
"heading": "Licensing Sentiment Detail",
|
||||
"body": "Actively Seeking Alternatives. Negative (Legal) designation on sentiment — suggests contract dispute, pricing disagreement, or legal proceedings. Both exec and tech sponsors are Negative. This is the most damaged relationship in Tier 1."
|
||||
},
|
||||
{
|
||||
"heading": "Regulatory Pressure",
|
||||
"body": "[{\"framework\":\"PCI-DSS\",\"deadline\":\"Ongoing\",\"status\":\"Compliant\",\"gap\":\"Declining on-prem PCI scope as stores close\",\"opportunity\":\"vDefend\"},{\"framework\":\"SOX\",\"deadline\":\"Ongoing\",\"status\":\"Compliant\",\"opportunity\":\"ACC\"}]"
|
||||
},
|
||||
{
|
||||
"heading": "Security Incidents",
|
||||
"body": "[{\"date\": \"2018-06\", \"type\": \"Data Breach\", \"description\": \"Online payment card compromise via Macys website. Customer payment information exposed.\", \"severity\": \"High\"}, {\"date\": \"2025-11\", \"type\": \"Ransomware/Extortion\", \"description\": \"Cl0p ransomware group listed Macy's on leak portal as part of coordinated campaign targeting Oracle E-Business Suite (EBS). Attackers exploited Oracle EBS vulnerabilities for unauthorized access. Customer PII, purchase history potentially compromised. Forensic firm hired to investigate.\", \"severity\": \"High\", \"regulatory_impact\": \"PCI-DSS implications, credit card company notifications\"}]"
|
||||
},
|
||||
{
|
||||
"heading": "Sales Campaign Intel",
|
||||
"body": "RETENTION EMERGENCY. The primary objective is preventing complete VMware abandonment. Legal sentiment must be resolved first. Any product conversation is secondary to the commercial/legal relationship repair. If the relationship cannot be saved, this becomes a managed churn account where the goal is maintaining the remaining 27K core revenue for as long as possible."
|
||||
},
|
||||
{
|
||||
"heading": "Campaign Evidence",
|
||||
"body": "{\"retention\":{\"competitive\":\"GCP full-stack migration underway\",\"regulatory\":\"\",\"priority\":\"Critical\"}}"
|
||||
},
|
||||
{
|
||||
"heading": "Broadcom Upsell Matrix",
|
||||
"body": "RETENTION FOCUS ONLY | Resolve Legal sentiment | Prove VCF value for remaining on-prem workloads | vDefend for PCI on remaining stores"
|
||||
},
|
||||
{
|
||||
"heading": "Gap Analysis",
|
||||
"body": "CRITICAL: Must resolve the Legal sentiment designation before any product conversation. What is the nature of the legal issue? Is it Broadcom pricing? Contract dispute? Until this is resolved, all other campaigns are irrelevant. Larry Del (Security, Neutral) may be the only non-hostile sponsor to engage."
|
||||
},
|
||||
{
|
||||
"heading": "Source URLs",
|
||||
"body": "macysinc.com | macys.com/careers/technology [WEB] ciodive.com/news/macys-retail-AI-automation | ciodive.com/news/macys-draws-on-google-cloud | cloud.google.com/blog/topics/customers/macys-google-cloud | forthepeople.com/blog/macys-data-breach | retaildive.com/news/macys-retail-AI"
|
||||
},
|
||||
{
|
||||
"heading": "Hiring Trends Summary",
|
||||
"body": "Heavily GCP-focused hiring. Minimal VMware roles. AI/ML hiring on Google Cloud. Digital commerce and e-commerce engineering."
|
||||
},
|
||||
{
|
||||
"heading": "Key Leadership Changes",
|
||||
"body": "Keith Credendino promoted to CIO (elevated from within) to accelerate modernization. Naveen Krishna serves as CTO (formerly VP Technology at The Home Depot — notable cross-pollination with another GCP customer). Krishna accelerated cloud adoption since joining."
|
||||
},
|
||||
{
|
||||
"heading": "IT Budget Signals",
|
||||
"body": "CapEx ~$1B annually 2025-2026. 2.5M sq ft automated fulfillment facility in NC. Q4 2025 earnings beat with upward 2026 forecast. 2026 guidance: net sales $21.4B-$21.65B, adjusted EPS $1.90-$2.10. AI inventory systems scaling. Supply chain savings target $235M annual run-rate by 2026."
|
||||
},
|
||||
{
|
||||
"heading": "SI Partners",
|
||||
"body": "Google Cloud (primary cloud) | Striim (data integration/migration)"
|
||||
},
|
||||
{
|
||||
"heading": "Renewal Timing Signals",
|
||||
"body": "Bold New Chapter strategy (2024) driving massive infrastructure refresh. Migrated mainframe/Oracle to GCP using Striim. New WMS on GCP. Closing 66 stores in 2025 — IT decommissioning opportunity. Reimagined store initiative covers 200 stores (~60% of go-forward fleet)."
|
||||
},
|
||||
{
|
||||
"heading": "Vendor Consolidation Intent",
|
||||
"body": "Active: Consolidating vendor base explicitly stated. Moving most apps from on-prem to cloud. Rationalizing supply chain assets. First advanced WMS replacing legacy systems. Outsourcing and tech integration part of Bold New Chapter."
|
||||
},
|
||||
{
|
||||
"heading": "Industry Peer Signals",
|
||||
"body": "Target uses cloud platforms for AI inventory/predictive analytics. Nordstrom investing $725M-$775M CapEx on digital/supply chain. VMware licensing disruption (Broadcom) forcing strategic review of private cloud across retail. Retail cloud market $39.6B by 2026 at 16.3% CAGR."
|
||||
},
|
||||
{
|
||||
"heading": "Peer Platform Moves",
|
||||
"body": "[{\"peer\":\"Target\",\"platform\":\"Cloud + AI for inventory/predictive analytics\",\"evidence\":\"Industry reports\",\"relevance\":\"Retail peer accelerating cloud AI adoption\"},{\"peer\":\"Nordstrom\",\"platform\":\"$725-775M CapEx on digital/supply chain\",\"evidence\":\"Nordstrom filings\",\"relevance\":\"Department store peer investing in similar tech\"},{\"peer\":\"Retail sector\",\"platform\":\"VMware/Broadcom licensing disruption triggering cloud strategy changes\",\"evidence\":\"Industry analysis\",\"relevance\":\"Broadcom pricing driving cloud migration across retail\"}]"
|
||||
},
|
||||
{
|
||||
"heading": "Edge & IoT Strategy",
|
||||
"body": "RFID tagging 100% of items. RFID + IoT sensors + video analytics + big data for inventory/loss prevention. RFID-enabled exit sensors for real-time theft detection. Personalized digital signage via edge computing. Cycle counts 25x faster with 20-30% inventory accuracy improvement."
|
||||
},
|
||||
{
|
||||
"heading": "Event & Conference Signals",
|
||||
"body": "Bold New Chapter strategy announced Feb 2024 at investor event. NRF 2025 featured reimagined stores updates. Q4 2025 earnings call credited AI, reimagined stores, loyalty data. No VMware Explore, AWS re:Invent, or Nutanix .NEXT appearances."
|
||||
},
|
||||
{
|
||||
"heading": "Additional Account Fields",
|
||||
"body": "Area: AM-SOUTHEAST EAR Tier: > $10M Est Annual Revenue: $23.9B (2024) Primary ERPs: 5224940 Account Plan: https://docs.google.com/presentation/d/1mStqs3Xx7VUtEaVJet8h6F9YkbwQK_bnHOKiiVO0Rx0/edit?usp=drive_link Account Folder: https://drive.google.com/drive/folders/1s0HmBfFiiuUDUkqgTjeKFkmUrVrk11D4 Account Website: macys.com VCF Entitled: Yes Exec Sponsorship Level: CIO-1 Broadcom Exec Sponsor Level: VP Last Engaged: 01/20/2026 Tech Sponsorship Level: CTO-1 Modern App Sponsor Level: CTO-1 Security Sponsor Level: CISO Other Sponsor: Bill Sway Other Sponsor Role: IT Director Other Sponsor Sentiment: Neutral Cloud Strategy Comments: GCP is primary target for all new workloads at this time --John Banerian, 02/09/2026 VCF Telemetry: RV Tools TAM Artifact Folder: https://drive.google.com/drive/folders/1e-Hz-E9bsfN5CFDMY5oh7AGj6SaTDFDR Datacenters: 1 Datacenter Cores: 16000 Edge Sites: 0 Edge Cores: 0 VCF Adoption Bottlenecks: Competing Priorities, Executive Sponsorship, Fragmented Tools, Operational Maturity, Hardware Contraints, Lack of Automation, Network Design Partner: NONE Container Platforms: Anthos , OSS K8s Production: Deployment Target Timeframe to VCF 9: FY26 Q2 Lab: Intended VCF 9 Version: VCF 9.1.x Lab: Deployment Target Timeframe to VCF 9: FY26 Q1 Lab Deployment Pilot?: Yes HW Blockers: Prod Environment VCF 9 Enablement Activities: VCF 9 SA-Led Workshop , Hands On Lab , VCF 9 Official Course , Compliance Scans Detailed 9.0 Software/Feature Gap: Right now there are no feature gaps that the account team is aware of customer is continuing to have VCF education and willingness to learn from the engineering level at Macy's. The executive level at Macy's is a different story and drags their feet in responding or engaging with Broadcom, there are still some legal challenges. Details are unknown from the account team and are being discussed at a higher level at Broadcom. VCF 9.x Comments: 08/06/2026 customer responded from procurement stating that they will not need the BU funded hundred and $50,000 worth of PSO to help them further their VCF test testing. This is very unfortunate because all we were able to complete is a basic VCF stand-up hundred percent believe this is a political move by Macy'sbecause of pricing and leadership being upset at the last couple of deals. 07/27/26 Macy's was about to lose all of their PSO credits in July 2026 at the last minute that they agreed to engage our PSO for a quick DCF 9.1 lab environment, install, and initial conversations on how to deploy this within their production environment. Chelsea from clear Bridge engaged with a customer as a consultant to install VCF in their lab environment. PSO was able to get approval to extend their PSO engagement until October 2026 and provide an additional BU funded $150,000.worth of PSO agreement will be sent to customer for signature. --Dan Palmer, 08/06/2026 Last Modified: 09/21/2026 Last Updated by: dan.palmer@broadcom.com Generated 9/22/2026, 9:17:06 PM by TechDirector Living Dossier District: SE-PEACHTREE Source doc refreshed nightly by TechDirector; the Drive URL is stable, so this source never needs re-importing. Source counts — Apps: 5, Workstreams: 6, Activities: 19, Consumption rows: 49, PSO Projects: 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
247
data/assessments/northwind-financial.json
Normal file
247
data/assessments/northwind-financial.json
Normal file
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"id": "northwind-financial",
|
||||
"version": 1,
|
||||
"customerName": "Northwind Financial",
|
||||
"industry": "Financial services",
|
||||
"preparedBy": "Chris Olson",
|
||||
"preparedFor": "CIO / Head of Infrastructure",
|
||||
"opportunityRef": "OPP-40118",
|
||||
"executiveSummary": "",
|
||||
"horizonYears": 3,
|
||||
"weights": {
|
||||
"capability": 35,
|
||||
"switchEase": 25,
|
||||
"timeToRoi": 25,
|
||||
"spend": 15
|
||||
},
|
||||
"candidates": [
|
||||
{
|
||||
"id": "c-gnmpnbd",
|
||||
"incumbentVendor": "F5",
|
||||
"incumbentProduct": "BIG-IP LTM",
|
||||
"domain": "adc",
|
||||
"productIds": [
|
||||
"ans-avi"
|
||||
],
|
||||
"annualSpend": 1250000,
|
||||
"units": 42,
|
||||
"unitLabel": "appliances",
|
||||
"renewalDate": "2027-03-31",
|
||||
"capability": 5,
|
||||
"switchEase": 4,
|
||||
"timeToRoiMonths": 6,
|
||||
"savingsPct": 55,
|
||||
"migrationCost": 220000,
|
||||
"strategicValue": 4,
|
||||
"risks": "iRules migration needs an audit; 6 legacy apps use custom TCL.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-mhfs3kf",
|
||||
"incumbentVendor": "Splunk",
|
||||
"incumbentProduct": "Enterprise",
|
||||
"domain": "observability",
|
||||
"productIds": [
|
||||
"vcf-ops-logs",
|
||||
"aod-dx-oi"
|
||||
],
|
||||
"annualSpend": 2100000,
|
||||
"units": 1400,
|
||||
"unitLabel": "GB/day",
|
||||
"renewalDate": "2026-12-01",
|
||||
"capability": 3,
|
||||
"switchEase": 2,
|
||||
"timeToRoiMonths": 18,
|
||||
"savingsPct": 45,
|
||||
"migrationCost": 480000,
|
||||
"strategicValue": 5,
|
||||
"risks": "SOC dashboards and 300+ saved searches must be rebuilt. Security team sign-off required.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-5f805x6",
|
||||
"incumbentVendor": "BMC",
|
||||
"incumbentProduct": "Control-M",
|
||||
"domain": "wla",
|
||||
"productIds": [
|
||||
"aod-automic",
|
||||
"aod-autosys"
|
||||
],
|
||||
"annualSpend": 880000,
|
||||
"units": 9500,
|
||||
"unitLabel": "jobs/day",
|
||||
"renewalDate": "2027-06-30",
|
||||
"capability": 5,
|
||||
"switchEase": 3,
|
||||
"timeToRoiMonths": 12,
|
||||
"savingsPct": 65,
|
||||
"migrationCost": 190000,
|
||||
"strategicValue": 4,
|
||||
"risks": "Batch calendar conversion over a single quarter-end freeze window.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-7zplvbf",
|
||||
"incumbentVendor": "CrowdStrike",
|
||||
"incumbentProduct": "Falcon",
|
||||
"domain": "endpoint-sec",
|
||||
"productIds": [
|
||||
"cb-cloud",
|
||||
"esg-ses-complete"
|
||||
],
|
||||
"annualSpend": 1450000,
|
||||
"units": 24000,
|
||||
"unitLabel": "endpoints",
|
||||
"renewalDate": "2027-01-15",
|
||||
"capability": 4,
|
||||
"switchEase": 3,
|
||||
"timeToRoiMonths": 9,
|
||||
"savingsPct": 40,
|
||||
"migrationCost": 260000,
|
||||
"strategicValue": 5,
|
||||
"risks": "Parallel-run required for 60 days; EDR detection parity must be proven to the CISO.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-nmte6kw",
|
||||
"incumbentVendor": "ServiceNow",
|
||||
"incumbentProduct": "ITSM",
|
||||
"domain": "itsm",
|
||||
"productIds": [
|
||||
"aod-sdm",
|
||||
"aod-service-catalog"
|
||||
],
|
||||
"annualSpend": 1900000,
|
||||
"units": 3100,
|
||||
"unitLabel": "fulfiller seats",
|
||||
"renewalDate": "2028-02-28",
|
||||
"capability": 3,
|
||||
"switchEase": 1,
|
||||
"timeToRoiMonths": 30,
|
||||
"savingsPct": 35,
|
||||
"migrationCost": 900000,
|
||||
"strategicValue": 3,
|
||||
"risks": "Deeply embedded workflows and 40+ integrations. Treat as a long-horizon option only.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-j12ddgu",
|
||||
"incumbentVendor": "SailPoint",
|
||||
"incumbentProduct": "IdentityIQ",
|
||||
"domain": "iam",
|
||||
"productIds": [
|
||||
"esg-iga"
|
||||
],
|
||||
"annualSpend": 720000,
|
||||
"units": 38000,
|
||||
"unitLabel": "identities",
|
||||
"renewalDate": "2027-09-30",
|
||||
"capability": 4,
|
||||
"switchEase": 3,
|
||||
"timeToRoiMonths": 14,
|
||||
"savingsPct": 50,
|
||||
"migrationCost": 170000,
|
||||
"strategicValue": 4,
|
||||
"risks": "Access certification campaigns must not lapse during cutover.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-a84itug",
|
||||
"incumbentVendor": "Nutanix",
|
||||
"incumbentProduct": "Cloud Platform",
|
||||
"domain": "private-cloud",
|
||||
"productIds": [
|
||||
"vcf-foundation",
|
||||
"vcf-vsan"
|
||||
],
|
||||
"annualSpend": 2600000,
|
||||
"units": 180,
|
||||
"unitLabel": "nodes",
|
||||
"renewalDate": "2027-04-30",
|
||||
"capability": 5,
|
||||
"switchEase": 4,
|
||||
"timeToRoiMonths": 8,
|
||||
"savingsPct": 30,
|
||||
"migrationCost": 400000,
|
||||
"strategicValue": 5,
|
||||
"risks": "AHV to ESXi conversion for 900 VMs; requires a rolling maintenance plan.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-lsuangg",
|
||||
"incumbentVendor": "Apptio",
|
||||
"incumbentProduct": "Cloudability",
|
||||
"domain": "finops",
|
||||
"productIds": [
|
||||
"aod-cloudhealth"
|
||||
],
|
||||
"annualSpend": 310000,
|
||||
"units": 0,
|
||||
"unitLabel": "",
|
||||
"renewalDate": "2026-11-30",
|
||||
"capability": 5,
|
||||
"switchEase": 5,
|
||||
"timeToRoiMonths": 4,
|
||||
"savingsPct": 70,
|
||||
"migrationCost": 25000,
|
||||
"strategicValue": 3,
|
||||
"risks": "",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-1krnhgi",
|
||||
"incumbentVendor": "IBM",
|
||||
"incumbentProduct": "Db2 Tools for z/OS",
|
||||
"domain": "mf-database",
|
||||
"productIds": [
|
||||
"msd-db2-suite",
|
||||
"msd-db2-utility"
|
||||
],
|
||||
"annualSpend": 1100000,
|
||||
"units": 2400,
|
||||
"unitLabel": "MSU",
|
||||
"renewalDate": "2027-12-31",
|
||||
"capability": 5,
|
||||
"switchEase": 4,
|
||||
"timeToRoiMonths": 10,
|
||||
"savingsPct": 45,
|
||||
"migrationCost": 150000,
|
||||
"strategicValue": 4,
|
||||
"risks": "Utility JCL conversion; run both suites in parallel for one reorg cycle.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
},
|
||||
{
|
||||
"id": "c-g5v54bd",
|
||||
"incumbentVendor": "Zscaler",
|
||||
"incumbentProduct": "Internet Access",
|
||||
"domain": "network-sec",
|
||||
"productIds": [
|
||||
"esg-cloud-swg"
|
||||
],
|
||||
"annualSpend": 960000,
|
||||
"units": 24000,
|
||||
"unitLabel": "users",
|
||||
"renewalDate": "2027-07-31",
|
||||
"capability": 4,
|
||||
"switchEase": 2,
|
||||
"timeToRoiMonths": 16,
|
||||
"savingsPct": 45,
|
||||
"migrationCost": 280000,
|
||||
"strategicValue": 4,
|
||||
"risks": "PAC file and certificate rollout across 24k endpoints and 60 branch sites.",
|
||||
"notes": "",
|
||||
"included": true
|
||||
}
|
||||
],
|
||||
"createdAt": "2026-09-22T17:22:56.750Z",
|
||||
"updatedAt": "2026-09-23T12:58:32.799Z"
|
||||
}
|
||||
3054
data/catalog.json
Normal file
3054
data/catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
16625
data/knowledge-base.json
Normal file
16625
data/knowledge-base.json
Normal file
File diff suppressed because it is too large
Load Diff
401
data/surveys/cd5bf9c3743e069cb807de8d42f3642c.json
Normal file
401
data/surveys/cd5bf9c3743e069cb807de8d42f3642c.json
Normal file
@@ -0,0 +1,401 @@
|
||||
{
|
||||
"id": "cd5bf9c3743e069cb807de8d42f3642c",
|
||||
"assessmentId": "guidewell",
|
||||
"respondentName": "John Doe, AD",
|
||||
"domains": [
|
||||
"private-cloud",
|
||||
"compute",
|
||||
"storage",
|
||||
"networking",
|
||||
"adc",
|
||||
"resiliency",
|
||||
"cloud-mgmt",
|
||||
"observability",
|
||||
"app-platform",
|
||||
"data",
|
||||
"devops",
|
||||
"finops",
|
||||
"itsm",
|
||||
"vsm",
|
||||
"wla",
|
||||
"api",
|
||||
"network-sec",
|
||||
"data-sec",
|
||||
"iam",
|
||||
"email-sec",
|
||||
"mf-security",
|
||||
"mf-devops",
|
||||
"mf-database",
|
||||
"mf-observability",
|
||||
"mf-storage",
|
||||
"mf-wla",
|
||||
"mf-foundation"
|
||||
],
|
||||
"answers": {
|
||||
"private-cloud": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekfoz3-urgjo",
|
||||
"label": "Nutanix Cloud Platform",
|
||||
"kbEntryId": "nutanix-cloud-platform",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekfptn-o5207",
|
||||
"label": "Microsoft Azure Local",
|
||||
"kbEntryId": "microsoft-azure-local",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekftsw-w3zzq",
|
||||
"label": "Red Hat OpenShift Telco",
|
||||
"kbEntryId": "red-hat-openshift-telco",
|
||||
"annualSpend": 500000,
|
||||
"contractEnd": "2027-03"
|
||||
}
|
||||
]
|
||||
},
|
||||
"compute": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekgda3-crvx0",
|
||||
"label": "Nutanix AHV",
|
||||
"kbEntryId": "nutanix-ahv",
|
||||
"annualSpend": 100000,
|
||||
"contractEnd": "2027-06"
|
||||
}
|
||||
]
|
||||
},
|
||||
"storage": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekgpc4-srsz0",
|
||||
"label": "Nutanix Distributed Storage",
|
||||
"kbEntryId": "nutanix-distributed-storage",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekgpvl-jn9wu",
|
||||
"label": "Dell PowerFlex",
|
||||
"kbEntryId": "dell-powerflex",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekgqk4-567qw",
|
||||
"label": "NetApp/Pure external arrays",
|
||||
"kbEntryId": "netapp-pure-external-arrays",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"networking": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekgtzu-3wgtm",
|
||||
"label": "Cisco ACI",
|
||||
"kbEntryId": "cisco-aci",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"adc": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekgx67-suphf",
|
||||
"label": "F5 BIG-IP LTM",
|
||||
"kbEntryId": "f5-big-ip-ltm",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekgzkj-is69n",
|
||||
"label": "F5 BIG-IP",
|
||||
"kbEntryId": "f5-big-ip",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"resiliency": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekh36b-df36q",
|
||||
"label": "Cohesity",
|
||||
"kbEntryId": "cohesity",
|
||||
"annualSpend": 450000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"cloud-mgmt": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekheml-vgnj3",
|
||||
"label": "Chef",
|
||||
"kbEntryId": "chef",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekhfca-lb8ot",
|
||||
"label": "Puppet Enterprise",
|
||||
"kbEntryId": "puppet-enterprise",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"observability": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekhkbk-2rki6",
|
||||
"label": "Splunk Enterprise",
|
||||
"kbEntryId": "splunk-enterprise",
|
||||
"annualSpend": 2250000,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekhpl5-j3igh",
|
||||
"label": "Cisco ThousandEyes + Nexus Dashboard",
|
||||
"kbEntryId": "cisco-thousandeyes-nexus-dashboard",
|
||||
"annualSpend": 250000,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekhr5q-hd6fy",
|
||||
"label": "Datadog",
|
||||
"kbEntryId": "datadog",
|
||||
"annualSpend": 1250000,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-muekhwpz-w0a9d",
|
||||
"label": "Dynatrace",
|
||||
"kbEntryId": "dynatrace",
|
||||
"annualSpend": 2300000,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-mueki0ku-w72w3",
|
||||
"label": "LogicMonitor",
|
||||
"kbEntryId": "logicmonitor",
|
||||
"annualSpend": 250000,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-mueki3pd-lr5ey",
|
||||
"label": "SolarWinds",
|
||||
"kbEntryId": "solarwinds",
|
||||
"annualSpend": 125000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"app-platform": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekitfg-zbwyl",
|
||||
"label": "Rancher",
|
||||
"kbEntryId": "rancher",
|
||||
"annualSpend": 550000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"devops": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekj50c-laf0x",
|
||||
"label": "Delphix",
|
||||
"kbEntryId": "delphix",
|
||||
"annualSpend": 750000,
|
||||
"contractEnd": "2027-06"
|
||||
},
|
||||
{
|
||||
"key": "t-muekjdkr-xfjd2",
|
||||
"label": "Perforce",
|
||||
"kbEntryId": "perforce",
|
||||
"annualSpend": 250000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"itsm": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekjr7o-vp4oh",
|
||||
"label": "ServiceNow ITSM",
|
||||
"kbEntryId": "servicenow-itsm",
|
||||
"annualSpend": 5000000,
|
||||
"contractEnd": "2028-10"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vsm": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekk1dv-2js6y",
|
||||
"label": "Planview AgilePlace",
|
||||
"kbEntryId": "planview-agileplace",
|
||||
"annualSpend": 2000000,
|
||||
"contractEnd": "2027-05"
|
||||
}
|
||||
]
|
||||
},
|
||||
"wla": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekkajl-r7za9",
|
||||
"label": "BMC Control-M",
|
||||
"kbEntryId": "bmc-control-m",
|
||||
"annualSpend": 1500000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"api": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekki5v-04x70",
|
||||
"label": "Apigee (Google)",
|
||||
"kbEntryId": "apigee-google",
|
||||
"annualSpend": 750000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"network-sec": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekkt27-lisf8",
|
||||
"label": "Zscaler Internet Access",
|
||||
"kbEntryId": "zscaler-internet-access",
|
||||
"annualSpend": 1250000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"data-sec": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekl1bq-g5pe9",
|
||||
"label": "Forcepoint DLP",
|
||||
"kbEntryId": "forcepoint-dlp",
|
||||
"annualSpend": 750000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"iam": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekl5od-7qddz",
|
||||
"label": "SailPoint IdentityIQ",
|
||||
"kbEntryId": "sailpoint-identityiq",
|
||||
"annualSpend": 1250000,
|
||||
"contractEnd": "2027-06"
|
||||
},
|
||||
{
|
||||
"key": "t-muekl9fb-gsx4o",
|
||||
"label": "CyberArk PAM",
|
||||
"kbEntryId": "cyberark-pam",
|
||||
"annualSpend": 1000000,
|
||||
"contractEnd": ""
|
||||
},
|
||||
{
|
||||
"key": "t-mueklcds-ztokj",
|
||||
"label": "Okta Workforce Identity",
|
||||
"kbEntryId": "okta-workforce-identity",
|
||||
"annualSpend": 1500000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"email-sec": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-mueklvtk-2nceo",
|
||||
"label": "Proofpoint Email Protection",
|
||||
"kbEntryId": "proofpoint-email-protection",
|
||||
"annualSpend": 250000,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"mf-security": {
|
||||
"status": "tools",
|
||||
"tools": [
|
||||
{
|
||||
"key": "t-muekm1z7-mamo3",
|
||||
"label": "IBM RACF",
|
||||
"kbEntryId": "ibm-racf",
|
||||
"annualSpend": null,
|
||||
"contractEnd": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": "submitted",
|
||||
"review": {
|
||||
"t-muekfoz3-urgjo": "accepted",
|
||||
"t-muekfptn-o5207": "accepted",
|
||||
"t-muekftsw-w3zzq": "accepted",
|
||||
"t-muekgda3-crvx0": "accepted",
|
||||
"t-muekgpc4-srsz0": "accepted",
|
||||
"t-muekgpvl-jn9wu": "accepted",
|
||||
"t-muekgqk4-567qw": "accepted",
|
||||
"t-muekgtzu-3wgtm": "accepted",
|
||||
"t-muekgx67-suphf": "accepted",
|
||||
"t-muekgzkj-is69n": "accepted",
|
||||
"t-muekh36b-df36q": "accepted",
|
||||
"t-muekheml-vgnj3": "accepted",
|
||||
"t-muekhfca-lb8ot": "accepted",
|
||||
"t-muekhkbk-2rki6": "accepted",
|
||||
"t-muekhpl5-j3igh": "accepted",
|
||||
"t-muekhr5q-hd6fy": "accepted",
|
||||
"t-muekhwpz-w0a9d": "accepted",
|
||||
"t-mueki0ku-w72w3": "accepted",
|
||||
"t-mueki3pd-lr5ey": "accepted",
|
||||
"t-muekitfg-zbwyl": "accepted",
|
||||
"t-muekj50c-laf0x": "accepted",
|
||||
"t-muekjdkr-xfjd2": "accepted",
|
||||
"t-muekjr7o-vp4oh": "accepted",
|
||||
"t-muekk1dv-2js6y": "accepted",
|
||||
"t-muekkajl-r7za9": "accepted",
|
||||
"t-muekki5v-04x70": "accepted",
|
||||
"t-muekkt27-lisf8": "accepted",
|
||||
"t-muekl1bq-g5pe9": "accepted",
|
||||
"t-muekl5od-7qddz": "accepted",
|
||||
"t-muekl9fb-gsx4o": "accepted",
|
||||
"t-mueklcds-ztokj": "accepted",
|
||||
"t-mueklvtk-2nceo": "accepted",
|
||||
"t-muekm1z7-mamo3": "accepted"
|
||||
},
|
||||
"createdAt": "2026-09-23T20:38:13.496Z",
|
||||
"updatedAt": "2026-09-23T20:44:27.986Z",
|
||||
"submittedAt": "2026-09-23T20:44:01.705Z"
|
||||
}
|
||||
3
next.config.ts
Normal file
3
next.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = { reactStrictMode: true };
|
||||
export default nextConfig;
|
||||
1948
package-lock.json
generated
Normal file
1948
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "rationalization-tool",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3500",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3500",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "15.5.4",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"docx": "^9.5.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"
|
||||
}
|
||||
}
|
||||
1
postcss.config.mjs
Normal file
1
postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export default { plugins: { "@tailwindcss/postcss": {} } };
|
||||
12
scripts/build-test-lib.sh
Executable file
12
scripts/build-test-lib.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compiles the pure-logic libs so tests can exercise the real source under Node.
|
||||
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 \
|
||||
--outDir .test-build --target ES2022 --module esnext \
|
||||
--moduleResolution bundler --skipLibCheck
|
||||
# tsc emits extensionless relative imports; Node ESM needs the .js suffix.
|
||||
for f in .test-build/*.js; do
|
||||
perl -pi -e 's{(from\s+")(\./[^"]+?)(")}{$1.$2.".js".$3}ge unless /\.js"/' "$f"
|
||||
done
|
||||
112
scripts/fixtures/dossier-sample.txt
Normal file
112
scripts/fixtures/dossier-sample.txt
Normal file
@@ -0,0 +1,112 @@
|
||||
GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA — Strategic Account Dossier
|
||||
|
||||
Complete account intelligence — every field TechDirector tracks for this account.
|
||||
|
||||
Executive Summary
|
||||
|
||||
Company: GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA
|
||||
Industry: Health Care Equipment & Services
|
||||
Employees: Not available
|
||||
Annual Revenue: Not available
|
||||
Classification: Tier 1 / Strategic
|
||||
Priority Flags: MVP (Most Valuable Partner — priority account requiring focused attention)
|
||||
Account Status: Active
|
||||
Account Sentiment: Neutral
|
||||
Renewal Timeframe: 2028 Q 2
|
||||
Renewal Risk: 5
|
||||
|
||||
Composite Scores
|
||||
|
||||
Account Health Score: n/a/100
|
||||
- Subscores: Data Completeness 0 / VCF Footprint 8 / Deployment Rate 30 / Engagement 17 / Risk Profile 14
|
||||
Intelligence Score: 98/100
|
||||
TAM Value Score: 39/100
|
||||
|
||||
Public Company Profile
|
||||
|
||||
• Area: AM-SOUTHEAST
|
||||
• District: SE-SUNSHINE
|
||||
• Account Website: guidewell.com
|
||||
• Public/Private: Private
|
||||
|
||||
VCF Status
|
||||
|
||||
VCF Plan Health: Yellow
|
||||
VCF Version: vSphere 8.x
|
||||
VCF Cores Entitled: 35768
|
||||
Cores by Version: vSphere 8.x: 35768
|
||||
Deployment Model: Standard
|
||||
VCF Path: Brownfield
|
||||
Adoption Stage: No VCF Adoption
|
||||
VCF 9 Plans: VCF 9.1.x
|
||||
VCF 9 Blockers: No Blockers
|
||||
|
||||
Infrastructure & Cloud Strategy
|
||||
|
||||
Cloud Strategy: Hybrid (Public+Private)
|
||||
Primary Hyperscaler: AWS, Azure
|
||||
Workload Distribution: 25% Public Cloud, 50% Private Cloud, 25% Mainframe
|
||||
Compute Vendor: HPE
|
||||
Storage Platform: Everpure (Pure)
|
||||
Network Vendor: Cisco
|
||||
Firewall Vendor: Zscaler
|
||||
Load Balancer Vendor: F5
|
||||
|
||||
Product Consumption
|
||||
|
||||
Overall Deployment Rate: 76% (6574253 / 8691992)
|
||||
|
||||
Products
|
||||
|
||||
• VSPHERE: Entitled 35768, Deployed 33400, Adoption High
|
||||
• VSAN: Entitled 480, Deployed 0, Adoption Shelfware
|
||||
• NSX: Entitled 0, Deployed 0, Adoption Shelfware
|
||||
• VCF OPERATIONS: Entitled 10000, Deployed 0, Adoption Shelfware
|
||||
• VCF SDDC: Entitled 0, Deployed 0, Adoption Shelfware
|
||||
• VCF AUTOMATION: Entitled 0, Deployed 0, Adoption Shelfware
|
||||
• VKS: Entitled 0, Deployed 0, Adoption Shelfware
|
||||
|
||||
Applications
|
||||
|
||||
ERP (4)
|
||||
|
||||
• Peoplesoft FSCM
|
||||
• PeopleSoft HCM
|
||||
• SAP Finance
|
||||
• Oracle HCM
|
||||
|
||||
Financial (8)
|
||||
|
||||
• Diamond
|
||||
• QNXT
|
||||
• HITS/CAPS
|
||||
• AccumWise/Accumuilators
|
||||
• PBM Prime
|
||||
• PMT (Product Management Tools)
|
||||
• Enrollpoint
|
||||
• Saphire/NASCO
|
||||
|
||||
Other (6)
|
||||
|
||||
• Florida Blue Portal
|
||||
• Omnissa Virtual Desktop
|
||||
• Prior Authorization AI
|
||||
• IBM API Connect / Datapower
|
||||
• GWDP (Guidewell Data Platform)
|
||||
• Online Health/ Well Frame
|
||||
|
||||
SaaS (1)
|
||||
|
||||
• Member Website/Mobile App (MWS)
|
||||
|
||||
Tech Debt Signals
|
||||
|
||||
Legacy health insurance claims systems. Mainframe at 15% for core claims processing. GuideWell subsidiary systems may be on separate infrastructure stacks.
|
||||
|
||||
Cloud Strategy Assessment
|
||||
|
||||
80% public cloud is the highest ratio for any health insurance account in the portfolio. This extreme cloud adoption for a HIPAA-regulated entity suggests either a cloud-native modernization strategy or that most workloads are SaaS rather than IaaS.
|
||||
|
||||
DevOps Platform Assessment
|
||||
|
||||
OpenShift Pipelines for container CI/CD. Limited DevOps tooling documented.
|
||||
94
scripts/seed-kb.mjs
Normal file
94
scripts/seed-kb.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Builds data/knowledge-base.json from the catalog's `replaces` lists.
|
||||
*
|
||||
* Each competitor string named by a product becomes one entry mapped to every
|
||||
* product that claims to displace it. Scores start at neutral defaults and are
|
||||
* refined automatically as real assessments are saved.
|
||||
*
|
||||
* Re-running is safe: existing entries keep their edits, observations and pins,
|
||||
* and only gain newly-discovered product mappings.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
|
||||
const CATALOG = "data/catalog.json";
|
||||
const KB = "data/knowledge-base.json";
|
||||
|
||||
const MULTIWORD_VENDORS = [
|
||||
"Red Hat", "Palo Alto", "Check Point", "Micro Focus", "Trend Micro", "Arctic Wolf", "Red Canary",
|
||||
"Ping Identity", "One Identity", "Salt Security", "Noname Security", "Traceable AI", "Aqua Security",
|
||||
"Airlock Digital", "Abnormal Security", "Menlo Security", "Recorded Future", "Scale Computing",
|
||||
"Wind River", "Software AG", "Cato Networks", "Zero Networks", "Digital Guardian", "Snow Software",
|
||||
"Cardinal Commerce", "Duo Security", "Crunchy Data", "Morpheus Data", "Sumo Logic", "Prisma Cloud",
|
||||
"Amazon Web Services", "Google Cloud", "Data Theorem",
|
||||
];
|
||||
|
||||
const splitVendor = (label) => {
|
||||
const clean = label.replace(/\s+/g, " ").trim();
|
||||
for (const v of MULTIWORD_VENDORS) {
|
||||
if (clean.toLowerCase().startsWith(v.toLowerCase() + " ")) return { vendor: v, product: clean.slice(v.length).trim() };
|
||||
if (clean.toLowerCase() === v.toLowerCase()) return { vendor: v, product: "" };
|
||||
}
|
||||
const parts = clean.split(" ");
|
||||
return parts.length === 1 ? { vendor: clean, product: "" } : { vendor: parts[0], product: parts.slice(1).join(" ") };
|
||||
};
|
||||
|
||||
const NON_COMPETITIVE = [
|
||||
/\(status quo\)/i, /\(internal alternative\)/i, /status quo/i,
|
||||
/\bDIY\b/i, /\bscripts?\b/i, /spreadsheets/i, /\bmanual\b/i,
|
||||
/^custom /i, /^native /i, /^internal /i, /^self-managed/i, /^unsupported/i,
|
||||
/^physical /i, /^consulting/i, /^fragmented/i, /^serialized/i, /^shared /i,
|
||||
/^point[- ]tool/i, /^point IAM/i, /^no\b/i, /^none\b/i, /^password-only/i,
|
||||
/^display statements/i, /^unencrypted/i, /^green[- ]screen/i, /^cron\b/i,
|
||||
/rewrite|redevelopment/i, /^migration to /i, /^rehost/i, /^low-code platforms/i,
|
||||
/^multiple /i, /^various /i, /^hand-written/i, /^ISPF /i, /^unsupported community/i,
|
||||
];
|
||||
const isCompetitive = (label) => !NON_COMPETITIVE.some((re) => re.test(label));
|
||||
|
||||
const kbId = (label) =>
|
||||
label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72) || "entry";
|
||||
|
||||
const catalog = JSON.parse(readFileSync(CATALOG, "utf8"));
|
||||
const existing = existsSync(KB) ? JSON.parse(readFileSync(KB, "utf8")) : { version: 1, entries: [] };
|
||||
const byId = new Map(existing.entries.map((e) => [e.id, e]));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let created = 0, enriched = 0;
|
||||
|
||||
for (const p of catalog.products) {
|
||||
for (const raw of p.replaces ?? []) {
|
||||
const label = raw.replace(/\s+/g, " ").trim();
|
||||
if (!label) continue;
|
||||
const id = kbId(label);
|
||||
|
||||
let e = byId.get(id);
|
||||
if (!e) {
|
||||
const { vendor, product } = splitVendor(label);
|
||||
e = {
|
||||
id, label, vendor, product, aliases: [], domain: p.domain,
|
||||
productIds: [], capability: 4, switchEase: 3, timeToRoiMonths: 12,
|
||||
savingsPct: 50, migrationCost: 0, unitLabel: "", risks: "", notes: "",
|
||||
competitive: isCompetitive(label),
|
||||
pinned: false, origin: "catalog", observations: [],
|
||||
createdAt: now, updatedAt: now,
|
||||
};
|
||||
byId.set(id, e);
|
||||
created++;
|
||||
}
|
||||
// Only add mappings; never strip a mapping a human curated.
|
||||
if (!e.productIds.includes(p.id)) {
|
||||
e.productIds.push(p.id);
|
||||
if (e.origin === "catalog") enriched++;
|
||||
}
|
||||
if (!e.domain) e.domain = p.domain;
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...byId.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
writeFileSync(KB, JSON.stringify({ version: 1, updatedAt: now, entries }, null, 2) + "\n");
|
||||
|
||||
// Backfill the flag for entries written before it existed.
|
||||
for (const e of entries) if (e.competitive === undefined) e.competitive = isCompetitive(e.label);
|
||||
|
||||
const mapped = entries.filter((e) => e.productIds.length > 0).length;
|
||||
const rivals = entries.filter((e) => e.competitive).length;
|
||||
console.log(`knowledge base: ${entries.length} entries (${created} new), ${mapped} mapped, ${rivals} true competitors, ${enriched} mappings added`);
|
||||
27
scripts/test-client.mjs
Normal file
27
scripts/test-client.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
// Exercises the exact regression: an empty 200 body must throw, not resolve to undefined.
|
||||
const { getJson, HttpError } = await import('/tmp/prt-test/client.mjs');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, ok) => { ok ? (pass++, console.log(' PASS', name)) : (fail++, console.log(' FAIL', name)); };
|
||||
|
||||
// 1. Real endpoint still works.
|
||||
const cat = await getJson('http://127.0.0.1:3500/api/catalog');
|
||||
check('catalog loads', cat.products.length === 179);
|
||||
|
||||
// 2. Empty 200 body -> throws (previously: JSON.parse crash that killed Promise.all).
|
||||
globalThis.fetch = async () => new Response('', { status: 200 });
|
||||
try { await getJson('/empty'); check('empty body throws', false); }
|
||||
catch (e) { check('empty body throws HttpError', e instanceof HttpError && /Empty response/.test(e.message)); }
|
||||
|
||||
// 3. 404 surfaces its status so the caller can show "not found" instead of an error.
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({error:'Not found'}), { status: 404 });
|
||||
try { await getJson('/missing'); check('404 throws', false); }
|
||||
catch (e) { check('404 carries status', e.status === 404 && e.message === 'Not found'); }
|
||||
|
||||
// 4. A transient failure is retried once and then succeeds.
|
||||
let n = 0;
|
||||
globalThis.fetch = async () => (++n === 1 ? new Response('', { status: 200 }) : new Response(JSON.stringify({ok:true}), { status: 200 }));
|
||||
check('retries once then succeeds', (await getJson('/flaky')).ok === true && n === 2);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
64
scripts/test-dossier.mjs
Normal file
64
scripts/test-dossier.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Parser tests run against the compiled lib so they exercise the real source.
|
||||
const { parseDossier, buildImportResult } = await import('../.test-build/dossier.js');
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const raw = readFileSync(new URL('./fixtures/dossier-sample.txt', import.meta.url), 'utf8');
|
||||
const kb = JSON.parse(readFileSync(new URL('../data/knowledge-base.json', import.meta.url), 'utf8'));
|
||||
const catalog = JSON.parse(readFileSync(new URL('../data/catalog.json', import.meta.url), 'utf8'));
|
||||
|
||||
let pass=0, fail=0;
|
||||
const t=(n,ok,extra='')=>{ok?(pass++,console.log(' PASS',n)):(fail++,console.log(' FAIL',n,extra))};
|
||||
|
||||
const intel = parseDossier(raw, 'https://docs.google.com/document/d/ABC/edit');
|
||||
|
||||
t('company', intel.company === 'GUIDEWELL BLUE CROSS BLUE SHIELD OF FLORIDA', intel.company);
|
||||
t('industry', intel.industry === 'Health Care Equipment & Services', intel.industry);
|
||||
t('classification', intel.classification === 'Tier 1 / Strategic');
|
||||
t('renewal timeframe', intel.renewalTimeframe === '2028 Q 2');
|
||||
t('"Not available" is dropped, not stored', !JSON.stringify(intel).includes('Not available'));
|
||||
t('source url retained', intel.sourceUrl.includes('docs.google.com'));
|
||||
|
||||
t('infrastructure vendors', intel.infrastructure.length === 5, JSON.stringify(intel.infrastructure));
|
||||
t('load balancer is F5', intel.infrastructure.some(f=>f.label==='Load Balancer Vendor' && f.value==='F5'));
|
||||
t('cloud facts', intel.cloud.length === 3);
|
||||
t('vcf facts captured', intel.vcf.some(f=>f.label==='VCF Cores Entitled' && f.value==='35768'));
|
||||
|
||||
t('consumption rows', intel.consumption.length === 7, String(intel.consumption.length));
|
||||
const vsphere = intel.consumption.find(c=>c.product==='VSPHERE');
|
||||
t('vsphere entitled/deployed parsed', vsphere.entitled===35768 && vsphere.deployed===33400 && vsphere.adoption==='High');
|
||||
t('deployment rate', intel.deploymentRate.startsWith('76%'));
|
||||
|
||||
t('apps parsed across categories', intel.applications.length === 19, String(intel.applications.length));
|
||||
t('app category assigned', intel.applications.find(a=>a.name==='SAP Finance')?.category === 'ERP');
|
||||
t('bullet chars stripped', intel.applications.every(a=>!/^[•\-\s]/.test(a.name)));
|
||||
|
||||
t('narratives captured', intel.narratives.length === 3, JSON.stringify(intel.narratives.map(n=>n.heading)));
|
||||
t('narrative body', /Mainframe at 15%/.test(intel.narratives.find(n=>n.heading==='Tech Debt Signals').body));
|
||||
|
||||
// ---- proposals ----
|
||||
const res = buildImportResult(raw, '', kb, catalog);
|
||||
const by = (l) => res.proposals.find(p=>p.label.toLowerCase().includes(l));
|
||||
|
||||
t('F5 proposed', !!by('f5'), res.proposals.map(p=>p.label).join(' | '));
|
||||
t('F5 maps to Avi', by('f5')?.productIds.includes('ans-avi'));
|
||||
t('Zscaler proposed', !!by('zscaler'));
|
||||
t('IBM API Connect proposed', !!by('api connect') || !!by('ibm api'));
|
||||
t('no duplicate KB entries', new Set(res.proposals.map(p=>p.kbEntryId)).size === res.proposals.length);
|
||||
t('all mapped product ids are real', res.proposals.every(p=>p.productIds.every(id=>catalog.products.some(x=>x.id===id))));
|
||||
t('proposals carry evidence', res.proposals.every(p=>p.evidence.includes(':')));
|
||||
t('sorted by confidence', res.proposals.map(p=>({high:0,medium:1,low:2})[p.confidence]).every((v,i,a)=>i===0||a[i-1]<=v));
|
||||
|
||||
t('shelfware = entitled>0 & deployed=0', res.shelfware.length===2 && res.shelfware.every(c=>c.entitled>0&&c.deployed===0), JSON.stringify(res.shelfware.map(c=>c.product)));
|
||||
t('vsphere not shelfware', !res.shelfware.some(c=>c.product==='VSPHERE'));
|
||||
|
||||
// Garbage in must not throw.
|
||||
const empty = buildImportResult('hello world', '', kb, catalog);
|
||||
t('junk input degrades gracefully', empty.proposals.length===0 && empty.warnings.length>0);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
console.log(`\nProposals from the sample dossier (${res.proposals.length}):`);
|
||||
for (const p of res.proposals) {
|
||||
const names = p.productIds.map(id=>catalog.products.find(x=>x.id===id)?.name).join(', ');
|
||||
console.log(` [${p.confidence.padEnd(6)}] ${p.label.padEnd(26)} → ${names || '(unmapped)'}`);
|
||||
}
|
||||
process.exit(fail?1:0);
|
||||
48
scripts/test-kb-learning.mjs
Normal file
48
scripts/test-kb-learning.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
const B='http://127.0.0.1:3500';
|
||||
const get=(u)=>fetch(B+u).then(r=>r.json());
|
||||
const put=(u,b)=>fetch(B+u,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
|
||||
let pass=0,fail=0; const t=(n,ok)=>{ok?(pass++,console.log(' PASS',n)):(fail++,console.log(' FAIL',n))};
|
||||
|
||||
const kb0 = await get('/api/knowledge-base');
|
||||
t('seeded KB loads', kb0.entries.length >= 630);
|
||||
|
||||
const f5 = kb0.entries.find(e=>e.label==='F5 BIG-IP');
|
||||
t('F5 BIG-IP pre-mapped to Avi', f5 && f5.productIds.includes('ans-avi'));
|
||||
t('seeded entry has no observations', f5.observations.length === 0);
|
||||
|
||||
// Save the seeded assessment -> should learn from its 10 candidates.
|
||||
const a = await get('/api/assessments/northwind-financial');
|
||||
await put('/api/assessments/northwind-financial', a);
|
||||
const kb1 = await get('/api/knowledge-base');
|
||||
|
||||
const learned = kb1.entries.filter(e=>e.observations.length>0);
|
||||
t('learned from all 10 candidates', learned.length === 10);
|
||||
|
||||
const splunk = kb1.entries.find(e=>e.label==='Splunk Enterprise');
|
||||
t('Splunk Enterprise captured', !!splunk);
|
||||
t('Splunk learned our real mapping', splunk.productIds.join()==='vcf-ops-logs,aod-dx-oi');
|
||||
t('Splunk learned switchEase 2', splunk.switchEase === 2);
|
||||
t('Splunk learned savingsPct 45', splunk.savingsPct === 45);
|
||||
t('Splunk learned risks text', /SOC dashboards/.test(splunk.risks));
|
||||
t('observation links back to assessment', splunk.observations[0].assessmentId === 'northwind-financial');
|
||||
|
||||
// Re-saving must update in place, not stack duplicates.
|
||||
await put('/api/assessments/northwind-financial', a);
|
||||
const kb2 = await get('/api/knowledge-base');
|
||||
const splunk2 = kb2.entries.find(e=>e.label==='Splunk Enterprise');
|
||||
t('re-save does not duplicate observations', splunk2.observations.length === 1);
|
||||
t('entry count stable after re-save', kb2.entries.length === kb1.entries.length);
|
||||
|
||||
// A pinned entry must survive learning untouched.
|
||||
const pinTarget = structuredClone(kb2);
|
||||
const p = pinTarget.entries.find(e=>e.label==='Splunk Enterprise');
|
||||
p.pinned = true; p.savingsPct = 99; p.switchEase = 5;
|
||||
await put('/api/knowledge-base', pinTarget);
|
||||
await put('/api/assessments/northwind-financial', a);
|
||||
const kb3 = await get('/api/knowledge-base');
|
||||
const splunk3 = kb3.entries.find(e=>e.label==='Splunk Enterprise');
|
||||
t('curated entry keeps savingsPct', splunk3.savingsPct === 99);
|
||||
t('curated entry keeps switchEase', splunk3.switchEase === 5);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail?1:0);
|
||||
39
scripts/test-kb-pruning.mjs
Normal file
39
scripts/test-kb-pruning.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
const B='http://127.0.0.1:3500';
|
||||
const get=(u)=>fetch(B+u).then(r=>r.json());
|
||||
const put=(u,b)=>fetch(B+u,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
|
||||
const del=(u)=>fetch(B+u,{method:'DELETE'}).then(r=>r.json());
|
||||
const post=(u,b)=>fetch(B+u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json());
|
||||
let pass=0,fail=0; const t=(n,ok)=>{ok?(pass++,console.log(' PASS',n)):(fail++,console.log(' FAIL',n))};
|
||||
const entry=(kb,l)=>kb.entries.find(e=>e.label===l);
|
||||
const cand=(o)=>({id:'c-x',incumbentVendor:'',incumbentProduct:'',domain:'',productIds:[],annualSpend:0,units:0,unitLabel:'',renewalDate:'',capability:4,switchEase:3,timeToRoiMonths:12,savingsPct:50,migrationCost:0,strategicValue:3,risks:'',notes:'',included:true,...o});
|
||||
|
||||
// Self-contained: create an assessment, learn from it, then take things away.
|
||||
const a = await post('/api/assessments', { customerName: 'Prune Test Co' });
|
||||
a.candidates = [
|
||||
cand({ id:'c-1', incumbentVendor:'CyberArk', incumbentProduct:'PAM', domain:'iam', productIds:['esg-pam'], annualSpend:400000, savingsPct:55 }),
|
||||
cand({ id:'c-2', incumbentVendor:'Okta', incumbentProduct:'Workforce Identity', domain:'iam', productIds:['esg-siteminder'], annualSpend:300000, savingsPct:40 }),
|
||||
];
|
||||
await put('/api/assessments/'+a.id, a);
|
||||
|
||||
const learned = await get('/api/knowledge-base');
|
||||
t('both candidates learned', entry(learned,'CyberArk PAM').observations.some(o=>o.assessmentId===a.id)
|
||||
&& entry(learned,'Okta Workforce Identity').observations.some(o=>o.assessmentId===a.id));
|
||||
t('learned the real savings figure', entry(learned,'CyberArk PAM').savingsPct === 55);
|
||||
|
||||
// Remove one candidate -> only its observation is pruned.
|
||||
a.candidates = a.candidates.filter(c => c.id !== 'c-1');
|
||||
await put('/api/assessments/'+a.id, a);
|
||||
const pruned = await get('/api/knowledge-base');
|
||||
t('removed candidate is forgotten', !entry(pruned,'CyberArk PAM').observations.some(o=>o.assessmentId===a.id));
|
||||
t('defaults revert to seeded values', entry(pruned,'CyberArk PAM').savingsPct === 50);
|
||||
t('the entry survives for reuse', entry(pruned,'CyberArk PAM').productIds.includes('esg-pam'));
|
||||
t('the other candidate is untouched', entry(pruned,'Okta Workforce Identity').observations.some(o=>o.assessmentId===a.id));
|
||||
|
||||
// Delete the assessment -> everything it taught is forgotten.
|
||||
await del('/api/assessments/'+a.id);
|
||||
const gone = await get('/api/knowledge-base');
|
||||
t('deleting the assessment forgets the rest', !entry(gone,'Okta Workforce Identity').observations.some(o=>o.assessmentId===a.id));
|
||||
t('unrelated learning is preserved', entry(gone,'Splunk Enterprise').observations.length === 1);
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail?1:0);
|
||||
79
scripts/test-report-leak.mjs
Normal file
79
scripts/test-report-leak.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* The account dossier holds internal pricing strategy, competitive risk and
|
||||
* personal data. None of it may reach a customer deliverable.
|
||||
*
|
||||
* Self-contained: builds its own assessment, imports the sample dossier through
|
||||
* the real endpoint, asserts on the redacted projection and the generated Word
|
||||
* file, then removes itself.
|
||||
*
|
||||
* (The rendered report page is verified in a browser; its server-rendered shell
|
||||
* carries no data, so asserting on that HTML would pass vacuously.)
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const B = 'http://127.0.0.1:3500';
|
||||
const json = (u, o) => fetch(B + u, o).then(r => r.json());
|
||||
const send = (u, m, b) => json(u, { method: m, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) });
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const t = (n, ok, x = '') => { ok ? (pass++, console.log(' PASS', n)) : (fail++, console.log(' FAIL', n, x)); };
|
||||
|
||||
const text = readFileSync(new URL('./fixtures/dossier-sample.txt', import.meta.url), 'utf8');
|
||||
const SOURCE = 'https://docs.google.com/document/d/LEAKTEST/edit';
|
||||
|
||||
const a = await send('/api/assessments', 'POST', { customerName: 'Redaction Test Co' });
|
||||
try {
|
||||
const imported = await send('/api/assessments/x/intel', 'POST', { text, sourceUrl: SOURCE });
|
||||
t('dossier parsed for the test', imported.proposals.length > 0 && !!imported.intel.company);
|
||||
|
||||
a.accountIntel = imported.intel;
|
||||
a.candidates = imported.proposals.slice(0, 3).map((p, i) => ({
|
||||
id: `c-${i}`, incumbentVendor: p.vendor, incumbentProduct: p.product, domain: p.domain,
|
||||
productIds: p.productIds, annualSpend: 500000, units: 0, unitLabel: '', renewalDate: '',
|
||||
capability: p.capability, switchEase: p.switchEase, timeToRoiMonths: p.timeToRoiMonths,
|
||||
savingsPct: p.savingsPct, migrationCost: 50000, strategicValue: 3,
|
||||
risks: '', notes: `From account dossier — ${p.evidence}`, included: true,
|
||||
}));
|
||||
await send(`/api/assessments/${a.id}`, 'PUT', a);
|
||||
|
||||
const full = await json(`/api/assessments/${a.id}`);
|
||||
t('stored record keeps the dossier', !!full.accountIntel);
|
||||
t('stored record keeps internal notes', full.candidates.every(c => c.notes));
|
||||
|
||||
const safe = await json(`/api/assessments/${a.id}/report`);
|
||||
t('projection drops the dossier', safe.accountIntel === null);
|
||||
t('projection drops internal notes', safe.candidates.every(c => c.notes === ''));
|
||||
t('projection keeps every candidate', safe.candidates.length === full.candidates.length);
|
||||
t('projection keeps the mappings', safe.candidates.every((c, i) => c.productIds.join() === full.candidates[i].productIds.join()));
|
||||
t('projection keeps the spend figures', safe.candidates.every((c, i) => c.annualSpend === full.candidates[i].annualSpend));
|
||||
|
||||
// A .docx is a ZIP: the bytes must be inflated or a text search passes vacuously.
|
||||
const buf = Buffer.from(await (await fetch(`${B}/api/assessments/${a.id}/docx`)).arrayBuffer());
|
||||
const tmp = new URL('./.leak-test.docx', import.meta.url);
|
||||
writeFileSync(tmp, buf);
|
||||
const docx = execFileSync('unzip', ['-p', tmp.pathname, 'word/document.xml'], { maxBuffer: 32e6 }).toString('utf8');
|
||||
t('Word file is a readable docx', docx.includes('<w:document') && docx.length > 2000);
|
||||
|
||||
const safeText = JSON.stringify(safe);
|
||||
const secrets = [
|
||||
SOURCE, 'docs.google.com', 'Tier 1 / Strategic', 'Renewal Risk', 'Intelligence Score',
|
||||
'TechDirector', 'Shelfware', 'Tech Debt Signals', 'AM-SOUTHEAST', 'From account dossier',
|
||||
'Everpure', 'Workload Distribution',
|
||||
];
|
||||
for (const s of secrets) {
|
||||
const label = s.length > 30 ? s.slice(0, 30) + '…' : s;
|
||||
t(`projection omits "${label}"`, !safeText.includes(s));
|
||||
t(`Word omits "${label}"`, !docx.includes(s));
|
||||
}
|
||||
|
||||
t('Word still names the customer', docx.includes('Redaction Test Co'));
|
||||
t('Word still carries a mapped product', /Avi Load Balancer|VCF Networking|Layer7/.test(docx));
|
||||
|
||||
execFileSync('rm', ['-f', tmp.pathname]);
|
||||
} finally {
|
||||
await fetch(`${B}/api/assessments/${a.id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
45
scripts/test.sh
Executable file
45
scripts/test.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the API test suites against a dev server on :3500.
|
||||
# Each suite starts from a freshly seeded knowledge base so runs are repeatable.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
BASE=http://127.0.0.1:3500
|
||||
curl -sf "$BASE/api/catalog" -o /dev/null || { echo "Start the dev server first: npm run dev"; exit 1; }
|
||||
|
||||
reseed() {
|
||||
rm -f data/knowledge-base.json
|
||||
node scripts/seed-kb.mjs > /dev/null
|
||||
}
|
||||
|
||||
fail=0
|
||||
|
||||
echo "=== dossier parser ==="
|
||||
./scripts/build-test-lib.sh
|
||||
node scripts/test-dossier.mjs || fail=1
|
||||
|
||||
echo
|
||||
echo "=== knowledge base: learning ==="
|
||||
reseed
|
||||
node scripts/test-kb-learning.mjs || fail=1
|
||||
|
||||
echo
|
||||
echo "=== knowledge base: pruning ==="
|
||||
reseed
|
||||
curl -s "$BASE/api/assessments/northwind-financial" > /tmp/prt-nw.json
|
||||
curl -s -X PUT -H "Content-Type: application/json" -d @/tmp/prt-nw.json \
|
||||
"$BASE/api/assessments/northwind-financial" -o /dev/null
|
||||
node scripts/test-kb-pruning.mjs || fail=1
|
||||
|
||||
echo
|
||||
echo "=== customer-report redaction ==="
|
||||
node scripts/test-report-leak.mjs || fail=1
|
||||
|
||||
# Leave the knowledge base in a clean, fully-learned state.
|
||||
reseed
|
||||
curl -s -X PUT -H "Content-Type: application/json" -d @/tmp/prt-nw.json \
|
||||
"$BASE/api/assessments/northwind-financial" -o /dev/null
|
||||
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "All suites passed." || echo "Some suites failed."
|
||||
exit $fail
|
||||
187
src/app/api/assessments/[id]/docx/route.ts
Normal file
187
src/app/api/assessments/[id]/docx/route.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
AlignmentType,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import { getAssessment, getCatalog } from "@/lib/store";
|
||||
import { customerSafe } from "@/lib/redact";
|
||||
import { WAVE_BLURB, WAVE_LABEL, rollup } from "@/lib/scoring";
|
||||
import { money, monthsLabel } from "@/lib/format";
|
||||
|
||||
const INK = "0B0B0B";
|
||||
const MUTED = "52514E";
|
||||
const ACCENT = "2A78D6";
|
||||
|
||||
function cell(text: string, opts: { bold?: boolean; fill?: string; align?: "left" | "right" } = {}) {
|
||||
return new TableCell({
|
||||
shading: opts.fill ? { type: ShadingType.CLEAR, color: "auto", fill: opts.fill } : undefined,
|
||||
margins: { top: 80, bottom: 80, left: 120, right: 120 },
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: opts.align === "right" ? AlignmentType.RIGHT : AlignmentType.LEFT,
|
||||
children: [new TextRun({ text, bold: opts.bold, size: 19, color: opts.bold ? INK : MUTED })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][], aligns: ("left" | "right")[] = []) {
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: headers.map((h, i) => cell(h, { bold: true, fill: "F0EFEC", align: aligns[i] })),
|
||||
}),
|
||||
...rows.map((r) => new TableRow({ children: r.map((v, i) => cell(v, { align: aligns[i] })) })),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const h1 = (t: string) =>
|
||||
new Paragraph({ heading: HeadingLevel.HEADING_1, spacing: { before: 360, after: 160 }, children: [new TextRun({ text: t, bold: true, size: 30, color: INK })] });
|
||||
const h2 = (t: string) =>
|
||||
new Paragraph({ heading: HeadingLevel.HEADING_2, spacing: { before: 260, after: 120 }, children: [new TextRun({ text: t, bold: true, size: 24, color: INK })] });
|
||||
const p = (t: string, color = MUTED) =>
|
||||
new Paragraph({ spacing: { after: 120 }, children: [new TextRun({ text: t, size: 21, color })] });
|
||||
|
||||
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 prod = new Map(catalog.products.map((x) => [x.id, x]));
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
|
||||
|
||||
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)}%`],
|
||||
];
|
||||
|
||||
const children = [
|
||||
new Paragraph({ spacing: { after: 60 }, children: [new TextRun({ text: "SOFTWARE PORTFOLIO RATIONALIZATION", 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" })),
|
||||
|
||||
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,
|
||||
),
|
||||
]),
|
||||
|
||||
h1("The financial case"),
|
||||
table(["Measure", "Value"], kpis, ["left", "right"]),
|
||||
|
||||
h1("Rationalization roadmap"),
|
||||
];
|
||||
|
||||
for (const w of [1, 2, 3] as const) {
|
||||
const items = r.byWave[w];
|
||||
if (!items.length) continue;
|
||||
children.push(h2(WAVE_LABEL[w]));
|
||||
children.push(p(WAVE_BLURB[w]));
|
||||
children.push(
|
||||
table(
|
||||
["Incumbent tool", "Replaced by", "Annual spend", "Annual saving", "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.annualSavings),
|
||||
monthsLabel(m.paybackMonths),
|
||||
];
|
||||
}),
|
||||
["left", "left", "right", "right", "right"],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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("Assessment detail"));
|
||||
children.push(
|
||||
table(
|
||||
["Incumbent", "Domain", "Proposed platform", "Parity", "Switch ease", "Time to ROI", "Score"],
|
||||
r.included.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return [
|
||||
`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—",
|
||||
domainName(c.domain),
|
||||
c.productIds.map((pid) => prod.get(pid)?.name ?? pid).join(", ") || "—",
|
||||
`${c.capability}/5`,
|
||||
`${c.switchEase}/5`,
|
||||
`${c.timeToRoiMonths} mo`,
|
||||
m.score.toFixed(0),
|
||||
];
|
||||
}),
|
||||
["left", "left", "left", "right", "right", "right", "right"],
|
||||
),
|
||||
);
|
||||
|
||||
const risky = r.included.filter((c) => c.risks.trim());
|
||||
if (risky.length) {
|
||||
children.push(h1("Risks and dependencies"));
|
||||
for (const c of risky) {
|
||||
children.push(h2(`${c.incumbentVendor} ${c.incumbentProduct}`.trim()));
|
||||
children.push(p(c.risks));
|
||||
}
|
||||
}
|
||||
|
||||
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.`,
|
||||
),
|
||||
);
|
||||
|
||||
const doc = new Document({
|
||||
creator: a.preparedBy || "Converge",
|
||||
title: `${a.customerName} — Software Portfolio Rationalization`,
|
||||
styles: { default: { document: { run: { font: "Aptos", size: 21, color: MUTED } } } },
|
||||
sections: [{ properties: { page: { margin: { top: 1000, bottom: 1000, left: 1000, right: 1000 } } }, children }],
|
||||
});
|
||||
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
return new NextResponse(new Uint8Array(buf), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"Content-Disposition": `attachment; filename="${a.id}-rationalization.docx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
20
src/app/api/assessments/[id]/intel/route.ts
Normal file
20
src/app/api/assessments/[id]/intel/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCatalog, getKnowledgeBase } from "@/lib/store";
|
||||
import { buildImportResult } from "@/lib/dossier";
|
||||
|
||||
/**
|
||||
* Parses a pasted dossier and returns what it found. Deliberately read-only:
|
||||
* nothing is written until a seller accepts specific proposals in the UI.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
const { text, sourceUrl } = (await req.json()) as { text?: string; sourceUrl?: string };
|
||||
if (!text || !text.trim()) {
|
||||
return NextResponse.json({ error: "Paste the dossier text first." }, { status: 400 });
|
||||
}
|
||||
if (text.length > 2_000_000) {
|
||||
return NextResponse.json({ error: "That document is too large to parse." }, { status: 413 });
|
||||
}
|
||||
|
||||
const [kb, catalog] = await Promise.all([getKnowledgeBase(), getCatalog()]);
|
||||
return NextResponse.json(buildImportResult(text, (sourceUrl ?? "").trim(), kb, catalog));
|
||||
}
|
||||
15
src/app/api/assessments/[id]/report/route.ts
Normal file
15
src/app/api/assessments/[id]/report/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAssessment } from "@/lib/store";
|
||||
import { customerSafe } from "@/lib/redact";
|
||||
|
||||
/**
|
||||
* The customer-facing projection of an assessment.
|
||||
*
|
||||
* The report page reads this instead of the full record so internal-only
|
||||
* material never crosses the wire, rather than merely going unrendered.
|
||||
*/
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const a = await getAssessment(id);
|
||||
return a ? NextResponse.json(customerSafe(a)) : NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
23
src/app/api/assessments/[id]/route.ts
Normal file
23
src/app/api/assessments/[id]/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteAssessment, getAssessment, saveAssessment } from "@/lib/store";
|
||||
import type { Assessment } from "@/lib/types";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params;
|
||||
const a = await getAssessment(id);
|
||||
return a ? NextResponse.json(a) : NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PUT(req: Request, { params }: Ctx) {
|
||||
const { id } = await params;
|
||||
const body = (await req.json()) as Assessment;
|
||||
return NextResponse.json(await saveAssessment({ ...body, id }));
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Ctx) {
|
||||
const { id } = await params;
|
||||
await deleteAssessment(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
42
src/app/api/assessments/[id]/surveys/[token]/route.ts
Normal file
42
src/app/api/assessments/[id]/surveys/[token]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteSurvey, getSurvey, mutateSurvey } from "@/lib/store";
|
||||
|
||||
type Ctx = { params: Promise<{ id: string; token: string }> };
|
||||
|
||||
type Body =
|
||||
| { action: "reopen" }
|
||||
| { action: "review"; decisions: Record<string, "accepted" | "dismissed"> }
|
||||
| { action: "rename"; respondentName: string };
|
||||
|
||||
/** Seller-side changes. Answers are the respondent's and are never written here. */
|
||||
export async function PUT(req: Request, { params }: Ctx) {
|
||||
const { id, token } = await params;
|
||||
const body = (await req.json()) as Body;
|
||||
|
||||
const result = await mutateSurvey(token, (s) => {
|
||||
if (s.assessmentId !== id) return "Not found";
|
||||
if (body.action === "reopen") {
|
||||
s.status = "open";
|
||||
} else if (body.action === "review") {
|
||||
for (const [k, v] of Object.entries(body.decisions ?? {})) {
|
||||
if (v === "accepted" || v === "dismissed") s.review[k] = v;
|
||||
}
|
||||
} else if (body.action === "rename") {
|
||||
s.respondentName = String(body.respondentName ?? "").trim().slice(0, 120);
|
||||
} else {
|
||||
return "Unknown action";
|
||||
}
|
||||
});
|
||||
|
||||
if (result === null || result === "Not found") return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (typeof result === "string") return NextResponse.json({ error: result }, { status: 400 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, { params }: Ctx) {
|
||||
const { id, token } = await params;
|
||||
const s = await getSurvey(token);
|
||||
if (!s || s.assessmentId !== id) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
await deleteSurvey(token);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
25
src/app/api/assessments/[id]/surveys/route.ts
Normal file
25
src/app/api/assessments/[id]/surveys/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSurvey, getAssessment, getCatalog, listSurveys } from "@/lib/store";
|
||||
import { surveyableDomains } from "@/lib/survey";
|
||||
|
||||
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 listSurveys(id));
|
||||
}
|
||||
|
||||
export async function POST(req: Request, { params }: Ctx) {
|
||||
const { id } = await params;
|
||||
if (!(await getAssessment(id))) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const body = (await req.json()) as { respondentName?: string; domains?: string[] };
|
||||
// Keep catalog order and drop anything that is not a live category.
|
||||
const wanted = new Set(Array.isArray(body.domains) ? body.domains : []);
|
||||
const domains = surveyableDomains(await getCatalog()).map((d) => d.id).filter((d) => wanted.has(d));
|
||||
if (!domains.length) return NextResponse.json({ error: "Pick at least one category." }, { status: 400 });
|
||||
|
||||
const name = typeof body.respondentName === "string" ? body.respondentName.trim().slice(0, 120) : "";
|
||||
return NextResponse.json(await createSurvey(id, name, domains));
|
||||
}
|
||||
24
src/app/api/assessments/route.ts
Normal file
24
src/app/api/assessments/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAssessment, listAssessments, saveAssessment } from "@/lib/store";
|
||||
import type { Assessment } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listAssessments());
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
// An "import" payload carries a full candidate list; anything else is a fresh assessment.
|
||||
if (body?.import) {
|
||||
const src = body.import as Assessment;
|
||||
const created = await createAssessment({ ...src, candidates: src.candidates ?? [] });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
}
|
||||
return NextResponse.json(await createAssessment(body), { status: 201 });
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const a = (await req.json()) as Assessment;
|
||||
if (!a?.id) return NextResponse.json({ error: "Missing id" }, { status: 400 });
|
||||
return NextResponse.json(await saveAssessment(a));
|
||||
}
|
||||
21
src/app/api/catalog/route.ts
Normal file
21
src/app/api/catalog/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCatalog, saveCatalog } from "@/lib/store";
|
||||
import type { Catalog } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await getCatalog());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const body = (await req.json()) as Catalog;
|
||||
if (!Array.isArray(body?.products) || !Array.isArray(body?.businessUnits)) {
|
||||
return NextResponse.json({ error: "Malformed catalog" }, { status: 400 });
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const p of body.products) {
|
||||
if (!p.id || !p.name) return NextResponse.json({ error: "Product needs an id and a name" }, { status: 400 });
|
||||
if (ids.has(p.id)) return NextResponse.json({ error: `Duplicate product id: ${p.id}` }, { status: 400 });
|
||||
ids.add(p.id);
|
||||
}
|
||||
return NextResponse.json(await saveCatalog(body));
|
||||
}
|
||||
21
src/app/api/knowledge-base/route.ts
Normal file
21
src/app/api/knowledge-base/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getKnowledgeBase, saveKnowledgeBase } from "@/lib/store";
|
||||
import type { KnowledgeBase } from "@/lib/types";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await getKnowledgeBase());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const body = (await req.json()) as KnowledgeBase;
|
||||
if (!Array.isArray(body?.entries)) {
|
||||
return NextResponse.json({ error: "Malformed knowledge base" }, { status: 400 });
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const e of body.entries) {
|
||||
if (!e.id || !e.label) return NextResponse.json({ error: "Every entry needs an id and a label" }, { status: 400 });
|
||||
if (ids.has(e.id)) return NextResponse.json({ error: `Duplicate entry id: ${e.id}` }, { status: 400 });
|
||||
ids.add(e.id);
|
||||
}
|
||||
return NextResponse.json(await saveKnowledgeBase(body));
|
||||
}
|
||||
76
src/app/api/q/[token]/route.ts
Normal file
76
src/app/api/q/[token]/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAssessment, getCatalog, getKnowledgeBase, getSurvey, mutateSurvey } from "@/lib/store";
|
||||
import { domainOptions, sanitizeAnswers } from "@/lib/survey";
|
||||
import type { PublicSurvey, Survey } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The respondent's endpoint. Anyone holding the link reaches it, so it returns
|
||||
* only the category list and this questionnaire's own answers — never the
|
||||
* assessment's numbers, the dossier, or other questionnaires.
|
||||
*/
|
||||
|
||||
type Ctx = { params: Promise<{ token: string }> };
|
||||
|
||||
const notFound = () => NextResponse.json({ error: "This questionnaire link is not valid." }, { status: 404 });
|
||||
|
||||
async function toPublic(s: Survey): Promise<PublicSurvey | null> {
|
||||
const [a, catalog, kb] = await Promise.all([getAssessment(s.assessmentId), getCatalog(), getKnowledgeBase()]);
|
||||
if (!a) return null;
|
||||
const byId = new Map(catalog.domains.map((d) => [d.id, d]));
|
||||
return {
|
||||
id: s.id,
|
||||
customerName: a.customerName,
|
||||
respondentName: s.respondentName,
|
||||
status: s.status,
|
||||
answers: s.answers,
|
||||
categories: s.domains
|
||||
.filter((d) => byId.has(d))
|
||||
.map((d) => ({
|
||||
id: d,
|
||||
name: byId.get(d)!.name,
|
||||
description: byId.get(d)!.description ?? "",
|
||||
options: domainOptions(kb, d),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, { params }: Ctx) {
|
||||
const { token } = await params;
|
||||
const s = await getSurvey(token);
|
||||
const pub = s && (await toPublic(s));
|
||||
return pub ? NextResponse.json(pub) : notFound();
|
||||
}
|
||||
|
||||
/** Autosave. Body: { answers, respondentName? } */
|
||||
export async function PUT(req: Request, { params }: Ctx) {
|
||||
const { token } = await params;
|
||||
const body = (await req.json().catch(() => null)) as { answers?: unknown; respondentName?: unknown } | null;
|
||||
if (!body) return NextResponse.json({ error: "Malformed request" }, { status: 400 });
|
||||
|
||||
const result = await mutateSurvey(token, (s) => {
|
||||
if (s.status !== "open") return "This questionnaire has already been submitted.";
|
||||
s.answers = sanitizeAnswers(s.domains, body.answers);
|
||||
if (typeof body.respondentName === "string") s.respondentName = body.respondentName.trim().slice(0, 120);
|
||||
});
|
||||
if (result === null) return notFound();
|
||||
if (typeof result === "string") return NextResponse.json({ error: result }, { status: 409 });
|
||||
return NextResponse.json({ ok: true, updatedAt: result.updatedAt });
|
||||
}
|
||||
|
||||
/** Submit. Saves the final answers and locks the questionnaire. */
|
||||
export async function POST(req: Request, { params }: Ctx) {
|
||||
const { token } = await params;
|
||||
const body = (await req.json().catch(() => null)) as { answers?: unknown; respondentName?: unknown } | null;
|
||||
|
||||
const result = await mutateSurvey(token, (s) => {
|
||||
if (s.status !== "open") return "This questionnaire has already been submitted.";
|
||||
if (body?.answers) s.answers = sanitizeAnswers(s.domains, body.answers);
|
||||
if (typeof body?.respondentName === "string") s.respondentName = body.respondentName.trim().slice(0, 120);
|
||||
s.status = "submitted";
|
||||
s.submittedAt = new Date().toISOString();
|
||||
});
|
||||
if (result === null) return notFound();
|
||||
if (typeof result === "string") return NextResponse.json({ error: result }, { status: 409 });
|
||||
const pub = await toPublic(result);
|
||||
return pub ? NextResponse.json(pub) : notFound();
|
||||
}
|
||||
435
src/app/assessments/[id]/page.tsx
Normal file
435
src/app/assessments/[id]/page.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
"use client";
|
||||
|
||||
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 CandidateRow from "@/components/CandidateRow";
|
||||
import IntelImport from "@/components/IntelImport";
|
||||
import AccountIntelStrip from "@/components/AccountIntelStrip";
|
||||
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import SurveyPanel from "@/components/SurveyPanel";
|
||||
import SurveyReview, { kbEntryFor, toolVendorProduct } from "@/components/SurveyReview";
|
||||
import { type PendingTool, candidateKey, pendingTools, toolFacts } from "@/lib/survey";
|
||||
import { HttpError, errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
const newCandidate = (domain = ""): Candidate => ({
|
||||
id: `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
incumbentVendor: "", incumbentProduct: "", domain, productIds: [],
|
||||
annualSpend: 0, units: 0, unitLabel: "", renewalDate: "",
|
||||
capability: 4, switchEase: 3, timeToRoiMonths: 12,
|
||||
savingsPct: 60, migrationCost: 0,
|
||||
strategicValue: 3, risks: "", notes: "", included: true,
|
||||
});
|
||||
|
||||
export default function Workbench({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const [a, setA] = useState<Assessment | null>(null);
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [kb, setKb] = useState<KnowledgeBase | null>(null);
|
||||
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [surveys, setSurveys] = useState<Survey[]>([]);
|
||||
const [showSurveys, setShowSurveys] = useState(false);
|
||||
const [reviewing, setReviewing] = useState<Survey | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadError(""); setNotFound(false);
|
||||
|
||||
// The knowledge base only powers autocomplete, so a failure there must not
|
||||
// block the workbench: it resolves to null and the picker simply goes quiet.
|
||||
Promise.all([
|
||||
getJson<Assessment>(`/api/assessments/${id}`),
|
||||
getJson<Catalog>("/api/catalog"),
|
||||
getJson<KnowledgeBase>("/api/knowledge-base").catch(() => null),
|
||||
])
|
||||
.then(([as, cat, base]) => { if (!cancelled) { setA(as); setCatalog(cat); setKb(base); } })
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
// A missing assessment is a different story from a failed request.
|
||||
if (err instanceof HttpError && err.status === 404) setNotFound(true);
|
||||
else setLoadError(errorMessage(err));
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, attempt]);
|
||||
|
||||
// Questionnaires live in their own files, so a failure here never blocks the workbench.
|
||||
const loadSurveys = useCallback(() => {
|
||||
getJson<Survey[]>(`/api/assessments/${id}/surveys`).then(setSurveys).catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
// Respondents submit from elsewhere, so check again whenever the seller comes back to the tab.
|
||||
useEffect(() => {
|
||||
loadSurveys();
|
||||
window.addEventListener("focus", loadSurveys);
|
||||
return () => window.removeEventListener("focus", loadSurveys);
|
||||
}, [loadSurveys]);
|
||||
|
||||
// Debounced autosave: the workbench is a live editing surface, not a form.
|
||||
const save = useCallback((next: Assessment) => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
setStatus("saving");
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
await sendJson(`/api/assessments/${next.id}`, "PUT", next);
|
||||
setStatus("saved");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const update = useCallback((fn: (draft: Assessment) => void) => {
|
||||
setA((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = structuredClone(prev);
|
||||
fn(next);
|
||||
save(next);
|
||||
return next;
|
||||
});
|
||||
}, [save]);
|
||||
|
||||
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : null), [a, catalog]);
|
||||
|
||||
/** Accepted proposals become real candidates; the dossier itself stays internal. */
|
||||
function acceptIntel(result: IntelImportResult, chosen: IntelProposal[]) {
|
||||
update((d) => {
|
||||
d.accountIntel = result.intel;
|
||||
if (!d.industry && result.intel.industry) d.industry = result.intel.industry;
|
||||
|
||||
const existing = new Set(
|
||||
d.candidates.map((c) => `${c.incumbentVendor} ${c.incumbentProduct}`.trim().toLowerCase()),
|
||||
);
|
||||
for (const p of chosen) {
|
||||
const label = `${p.vendor} ${p.product}`.trim().toLowerCase();
|
||||
if (existing.has(label)) continue;
|
||||
existing.add(label);
|
||||
d.candidates.unshift({
|
||||
...newCandidate(p.domain),
|
||||
incumbentVendor: p.vendor,
|
||||
incumbentProduct: p.product,
|
||||
productIds: [...p.productIds],
|
||||
capability: p.capability,
|
||||
switchEase: p.switchEase,
|
||||
timeToRoiMonths: p.timeToRoiMonths,
|
||||
savingsPct: p.savingsPct,
|
||||
migrationCost: p.migrationCost,
|
||||
risks: p.risks,
|
||||
notes: `From account dossier — ${p.evidence}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
setShowImport(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepted answers become candidates seeded from the knowledge base; a tool
|
||||
* already on the assessment only has its blank spend and renewal filled in.
|
||||
*/
|
||||
async function acceptSurvey(sv: Survey, accepted: PendingTool[], dismissed: PendingTool[]) {
|
||||
const who = sv.respondentName || "questionnaire respondent";
|
||||
update((d) => {
|
||||
const byLabel = new Map(d.candidates.map((c) => [candidateKey(c.incumbentVendor, c.incumbentProduct), c]));
|
||||
for (const t of accepted) {
|
||||
const { vendor, product } = toolVendorProduct(kb, t);
|
||||
const facts = toolFacts(t);
|
||||
const hit = byLabel.get(candidateKey(vendor, product));
|
||||
if (hit) {
|
||||
if (!hit.annualSpend && facts.annualSpend) hit.annualSpend = facts.annualSpend;
|
||||
if (!hit.renewalDate && facts.renewalDate) hit.renewalDate = facts.renewalDate;
|
||||
continue;
|
||||
}
|
||||
const e = kbEntryFor(kb, t);
|
||||
const c: Candidate = {
|
||||
...newCandidate(t.domain),
|
||||
incumbentVendor: vendor,
|
||||
incumbentProduct: product,
|
||||
...facts,
|
||||
...(e ? {
|
||||
productIds: [...e.productIds], unitLabel: e.unitLabel,
|
||||
capability: e.capability, switchEase: e.switchEase, timeToRoiMonths: e.timeToRoiMonths,
|
||||
savingsPct: e.savingsPct, migrationCost: e.migrationCost, risks: e.risks,
|
||||
} : {}),
|
||||
notes: `From questionnaire — ${who}, ${dateLabel(sv.submittedAt)}`,
|
||||
};
|
||||
d.candidates.unshift(c);
|
||||
byLabel.set(candidateKey(vendor, product), c);
|
||||
}
|
||||
});
|
||||
setReviewing(null);
|
||||
|
||||
const decisions = Object.fromEntries([
|
||||
...accepted.map((t) => [t.key, "accepted"]),
|
||||
...dismissed.map((t) => [t.key, "dismissed"]),
|
||||
]);
|
||||
try {
|
||||
await sendJson(`/api/assessments/${id}/surveys/${sv.id}`, "PUT", { action: "review", decisions });
|
||||
} catch {
|
||||
// The tools are already on the assessment; the inbox just offers them again,
|
||||
// and a second accept only fills blanks on the lines it finds.
|
||||
} finally {
|
||||
loadSurveys();
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-[14px] font-medium">No assessment with the id <code>{id}</code>.</p>
|
||||
<Link href="/" className="btn mt-4">Back to assessments</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!a || !catalog || !r) 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";
|
||||
|
||||
function exportJson() {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(a, null, 2)], { type: "application/json" }));
|
||||
const el = document.createElement("a");
|
||||
el.href = url; el.download = `${a!.id}.json`; el.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const head = [
|
||||
"incumbent_vendor", "incumbent_product", "domain", "replacement_products", "annual_spend_usd",
|
||||
"quantity", "unit", "renewal_date", "capability_parity_1_5", "switch_ease_1_5", "time_to_roi_months",
|
||||
"strategic_value_1_5", "savings_pct", "migration_cost_usd", "annual_saving_usd",
|
||||
`net_benefit_${a!.horizonYears}yr_usd`, "payback_months", "score", "wave", "included", "risks", "notes",
|
||||
];
|
||||
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||
const lines = a!.candidates.map((c) => {
|
||||
const m = r!.metrics.get(c.id)!;
|
||||
return [
|
||||
c.incumbentVendor, c.incumbentProduct, domainName(c.domain),
|
||||
c.productIds.map((pid) => catalog!.products.find((p) => p.id === pid)?.name ?? pid).join("; "),
|
||||
c.annualSpend, c.units, c.unitLabel, c.renewalDate, c.capability, c.switchEase, c.timeToRoiMonths,
|
||||
c.strategicValue, c.savingsPct, c.migrationCost, Math.round(m.annualSavings), Math.round(m.horizonNet),
|
||||
m.paybackMonths === null ? "" : m.paybackMonths.toFixed(1), m.score.toFixed(0), m.wave,
|
||||
c.included, c.risks, c.notes,
|
||||
].map(esc).join(",");
|
||||
});
|
||||
const url = URL.createObjectURL(new Blob([[head.join(","), ...lines].join("\n")], { type: "text/csv" }));
|
||||
const el = document.createElement("a");
|
||||
el.href = url; el.download = `${a!.id}-rationalization.csv`; el.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const statusText = { idle: "", saving: "Saving…", saved: "Saved", error: "Save failed" }[status];
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
{/* ---- header ---- */}
|
||||
<div className="mb-5 flex flex-wrap items-start gap-4">
|
||||
<div className="min-w-0">
|
||||
<Link href="/" className="text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]">← Assessments</Link>
|
||||
<input
|
||||
className="-ml-1 mt-1 block w-full max-w-[520px] rounded-lg border border-transparent bg-transparent px-1 text-[28px] font-semibold tracking-tight outline-none hover:border-[var(--border)] focus:border-[var(--accent)]"
|
||||
value={a.customerName} aria-label="Customer name"
|
||||
onChange={(e) => update((d) => { d.customerName = e.target.value; })} />
|
||||
<button className="mt-1 text-[12.5px] text-[var(--text-muted)] hover:text-[var(--text-primary)]" onClick={() => setShowMeta((v) => !v)}>
|
||||
{[a.industry, a.preparedFor, a.preparedBy, a.opportunityRef].filter(Boolean).join(" · ") || "Add engagement details"} {showMeta ? "▴" : "▾"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<Link className="btn btn-primary" href={`/assessments/${a.id}/report`}>Customer report →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMeta && (
|
||||
<div className="card fade-up mb-5 grid gap-3 p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{([
|
||||
["industry", "Industry"], ["preparedFor", "Prepared for"],
|
||||
["preparedBy", "Prepared by"], ["opportunityRef", "Opportunity reference"],
|
||||
] as const).map(([k, label]) => (
|
||||
<div key={k}>
|
||||
<label className="label" htmlFor={`m-${k}`}>{label}</label>
|
||||
<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">
|
||||
<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}
|
||||
onChange={(e) => update((d) => { d.executiveSummary = e.target.value; })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{a.accountIntel && (
|
||||
<AccountIntelStrip
|
||||
intel={a.accountIntel}
|
||||
onClear={() => update((d) => { d.accountIntel = null; })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{surveys.filter((s) => pendingTools(s).length > 0).map((s) => (
|
||||
<div key={s.id} className="card fade-up mb-4 flex flex-wrap items-center gap-3 px-4 py-3" style={{ borderColor: "var(--accent)" }}>
|
||||
<span className="h-2 w-2 rounded-full bg-[var(--accent)]" />
|
||||
<span className="text-[13px]">
|
||||
<strong>{s.respondentName || "A respondent"}</strong> submitted the landscape questionnaire —{" "}
|
||||
{pendingTools(s).length} tool{pendingTools(s).length === 1 ? "" : "s"} to review.
|
||||
</span>
|
||||
<button className="btn btn-primary btn-sm ml-auto" onClick={() => setReviewing(s)}>Review</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showSurveys && (
|
||||
<SurveyPanel assessmentId={a.id} catalog={catalog} surveys={surveys} onChanged={loadSurveys}
|
||||
onClose={() => setShowSurveys(false)}
|
||||
onReview={(s) => { setShowSurveys(false); setReviewing(s); }} />
|
||||
)}
|
||||
|
||||
{reviewing && (
|
||||
<SurveyReview survey={reviewing} catalog={catalog} kb={kb} candidates={a.candidates}
|
||||
onClose={() => setReviewing(null)}
|
||||
onAccept={(acc, dis) => acceptSurvey(reviewing, acc, dis)} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
<div className="mb-5 grid gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<WeightsPanel a={a} update={update} />
|
||||
<ChartFrame
|
||||
title="Where to start"
|
||||
subtitle="Capability parity against ease of switching. Top-right moves first."
|
||||
table={{
|
||||
headers: ["Incumbent", "Parity", "Switch ease", "Spend", "Score"],
|
||||
rows: r.included.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return [`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "—", `${c.capability}/5`, `${c.switchEase}/5`, money(c.annualSpend, true), m.score.toFixed(0)];
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Quadrant
|
||||
groups={[{ label: WAVE_LABEL[1], colorIndex: 0 }, { label: WAVE_LABEL[2], colorIndex: 1 }, { label: WAVE_LABEL[3], colorIndex: 2 }]}
|
||||
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),
|
||||
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
|
||||
sublabel: c.productIds.map((pid) => catalog.products.find((p) => p.id === pid)?.name ?? pid).join(", ") || "No replacement mapped",
|
||||
colorIndex: m.wave - 1, groupLabel: WAVE_LABEL[m.wave],
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</div>
|
||||
|
||||
<ImplementationTimeline a={a} r={r} catalog={catalog} onParallel={(n) => update((d) => { d.planParallel = n; })} />
|
||||
|
||||
{/* ---- candidates ---- */}
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-[17px] font-semibold tracking-tight">Competitive landscape</h2>
|
||||
<span className="chip">{a.candidates.length} tools</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button className="btn" onClick={() => update((d) => {
|
||||
const seen = new Set(d.candidates.map((c) => c.id));
|
||||
const n = newCandidate();
|
||||
while (seen.has(n.id)) n.id += "x";
|
||||
d.candidates.unshift(n);
|
||||
})}>+ Add incumbent tool</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{a.candidates.length === 0 ? (
|
||||
<Empty>Add the customer’s incumbent tools to begin. Type a vendor and product and the tool suggests replacements from our portfolio.</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{a.candidates.map((c) => (
|
||||
<CandidateRow
|
||||
key={c.id} c={c} m={r.metrics.get(c.id)!} catalog={catalog} kb={kb}
|
||||
onChange={(next) => update((d) => { const i = d.candidates.findIndex((x) => x.id === c.id); if (i >= 0) d.candidates[i] = next; })}
|
||||
onDelete={() => update((d) => { d.candidates = d.candidates.filter((x) => x.id !== c.id); })}
|
||||
onDuplicate={() => update((d) => {
|
||||
const i = d.candidates.findIndex((x) => x.id === c.id);
|
||||
d.candidates.splice(i + 1, 0, { ...structuredClone(c), id: newCandidate().id });
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeightsPanel({ a, update }: { a: Assessment; update: (fn: (d: Assessment) => void) => void }) {
|
||||
const rows: [keyof Weights, string, string][] = [
|
||||
["capability", "Capability parity", "How completely we replace the incumbent"],
|
||||
["switchEase", "Ease of switching", "How little friction the migration carries"],
|
||||
["timeToRoi", "Time to ROI", "How quickly the customer sees payback"],
|
||||
["spend", "Spend at stake", "How much annual cost the line represents"],
|
||||
];
|
||||
const total = rows.reduce((s, [k]) => s + a.weights[k], 0);
|
||||
|
||||
return (
|
||||
<section className="card p-5">
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">Scoring weights</h3>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => update((d) => { d.weights = { ...DEFAULT_WEIGHTS }; })}>Reset</button>
|
||||
</div>
|
||||
<p className="mb-4 text-[12.5px] text-[var(--text-muted)]">
|
||||
Tune how the rationalization score is composed. Weights are normalised, so only their ratio matters.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rows.map(([k, label, hint]) => (
|
||||
<div key={k}>
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<label className="label mb-0" htmlFor={`w-${k}`}>{label}</label>
|
||||
<span className="ml-auto tabnum text-[12.5px] font-semibold">{total ? Math.round((a.weights[k] / total) * 100) : 0}%</span>
|
||||
</div>
|
||||
<input id={`w-${k}`} type="range" min={0} max={50} value={a.weights[k]}
|
||||
onChange={(e) => update((d) => { d.weights[k] = Number(e.target.value); })} />
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t pt-4">
|
||||
<label className="label" htmlFor="horizon">Planning horizon</label>
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 3, 5].map((y) => (
|
||||
<button key={y} id={y === 1 ? "horizon" : undefined}
|
||||
className={`btn btn-sm flex-1 justify-center ${a.horizonYears === y ? "btn-primary" : ""}`}
|
||||
onClick={() => update((d) => { d.horizonYears = y; })}>{y} year{y > 1 ? "s" : ""}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
284
src/app/assessments/[id]/report/page.tsx
Normal file
284
src/app/assessments/[id]/report/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
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 { dateLabel, money, monthsLabel } from "@/lib/format";
|
||||
import { ChartFrame, Empty, HBar, PaybackCurve, Quadrant, StatTile } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { BuBreakdown, ImplementationTimeline, ToolFootprintTile } from "@/components/Analysis";
|
||||
import { errorMessage, getJson } from "@/lib/client";
|
||||
|
||||
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
|
||||
|
||||
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 [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
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); } })
|
||||
.catch((err) => { if (!cancelled) setLoadError(errorMessage(err)); });
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, attempt]);
|
||||
|
||||
const r = useMemo(() => (a && catalog ? rollup(a, catalog) : 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>;
|
||||
|
||||
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 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)}.`;
|
||||
|
||||
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>
|
||||
<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>
|
||||
</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
|
||||
</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)]">
|
||||
{a.industry && <span>{a.industry}</span>}
|
||||
{a.preparedFor && <span>Prepared for {a.preparedFor}</span>}
|
||||
{a.preparedBy && <span>Prepared by {a.preparedBy}</span>}
|
||||
{a.opportunityRef && <span>Ref {a.opportunityRef}</span>}
|
||||
<span className="text-[var(--text-muted)]">{dateLabel(a.updatedAt)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{r.included.length === 0 ? (
|
||||
<Empty>No tools are included in this assessment yet. Add candidates in the workbench.</Empty>
|
||||
) : (
|
||||
<>
|
||||
{/* ---- executive summary ---- */}
|
||||
<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>)}
|
||||
</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>
|
||||
|
||||
{/* ---- 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>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* ---- roadmap ---- */}
|
||||
<section className="page-break mb-7">
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{([1, 2, 3] as const).map((w) => {
|
||||
const items = r.byWave[w];
|
||||
const savings = items.reduce((s, c) => s + r.metrics.get(c.id)!.annualSavings, 0);
|
||||
return (
|
||||
<div key={w} className="card avoid-break overflow-hidden">
|
||||
<div className="border-b px-4 py-3" style={{ borderTop: `3px solid ${WAVE_COLOR[w - 1]}` }}>
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">{WAVE_LABEL[w]}</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-4 py-5 text-center text-[12.5px] text-[var(--text-muted)]">Nothing in this wave.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{items.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return (
|
||||
<li key={c.id} className="px-4 py-3">
|
||||
<div className="text-[13px] font-medium">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled"}</div>
|
||||
<div className="mt-0.5 text-[12px] text-[var(--text-secondary)]">
|
||||
→ {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>{monthsLabel(m.paybackMonths)} payback</span>
|
||||
{c.renewalDate && <span>Renews {dateLabel(c.renewalDate)}</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- implementation plan ---- */}
|
||||
<ImplementationTimeline a={a} r={r} catalog={catalog} />
|
||||
|
||||
{/* ---- quadrant ---- */}
|
||||
<section className="mb-7">
|
||||
<ChartFrame
|
||||
title="Replacement readiness"
|
||||
subtitle="Capability parity against ease of switching, sized by annual spend at stake."
|
||||
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)]),
|
||||
}}
|
||||
>
|
||||
<Quadrant
|
||||
groups={[{ label: "Wave 1", colorIndex: 0 }, { label: "Wave 2", colorIndex: 1 }, { label: "Wave 3", colorIndex: 2 }]}
|
||||
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),
|
||||
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],
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</ChartFrame>
|
||||
|
||||
</section>
|
||||
|
||||
{/* ---- value by business unit ---- */}
|
||||
<section className="mb-7">
|
||||
<BuBreakdown r={r} catalog={catalog} horizonYears={a.horizonYears} />
|
||||
</section>
|
||||
|
||||
{/* ---- detail table ---- */}
|
||||
<section className="page-break mb-7">
|
||||
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Assessment detail</h2>
|
||||
<div className="card scroll-thin overflow-x-auto">
|
||||
<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) => (
|
||||
<th key={h} className={`px-3 py-2.5 font-semibold ${i >= 3 ? "text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.included.map((c) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-[var(--grid)] last:border-0">
|
||||
<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 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>
|
||||
<td className="px-3 py-2.5 text-right tabnum">{monthsLabel(m.paybackMonths)}</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<span className="chip" style={{ borderColor: WAVE_COLOR[m.wave - 1], color: WAVE_COLOR[m.wave - 1] }}>{m.wave}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<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" style={{ color: "var(--success-text)" }}>{money(r.totalAnnualSavings)}</td>
|
||||
<td className="px-3 py-2.5" colSpan={4} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ---- risks ---- */}
|
||||
{r.included.some((c) => c.risks.trim()) && (
|
||||
<section className="avoid-break mb-7">
|
||||
<h2 className="mb-3 text-[19px] font-semibold tracking-tight">Risks and dependencies</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{r.included.filter((c) => c.risks.trim()).map((c) => (
|
||||
<div key={c.id} className="card avoid-break p-4">
|
||||
<div className="text-[13px] font-semibold">{`${c.incumbentVendor} ${c.incumbentProduct}`.trim()}</div>
|
||||
<p className="mt-1.5 text-[12.5px] leading-relaxed text-[var(--text-secondary)]">{c.risks}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ---- method ---- */}
|
||||
<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.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
354
src/app/catalog/page.tsx
Normal file
354
src/app/catalog/page.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { BusinessUnit, Catalog, Product } from "@/lib/types";
|
||||
import { Empty } from "@/components/charts";
|
||||
import LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
const BLANK: Product = {
|
||||
id: "", name: "", bu: "", domain: "", description: "", replaces: [], url: "", enabled: true, notes: "",
|
||||
};
|
||||
|
||||
export default function CatalogPage() {
|
||||
const [cat, setCat] = useState<Catalog | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [bu, setBu] = useState("all");
|
||||
const [domain, setDomain] = useState("all");
|
||||
const [state, setState] = useState<"all" | "on" | "off">("all");
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
const [showBus, setShowBus] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadError("");
|
||||
getJson<Catalog>("/api/catalog")
|
||||
.then((c) => { if (!cancelled) setCat(c); })
|
||||
.catch((e) => { if (!cancelled) setLoadError(errorMessage(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [attempt]);
|
||||
|
||||
function mutate(fn: (c: Catalog) => void) {
|
||||
setCat((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = structuredClone(prev);
|
||||
fn(next);
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
setErr("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!cat) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
setCat(await sendJson<Catalog>("/api/catalog", "PUT", cat));
|
||||
setDirty(false);
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!cat) return [];
|
||||
const needle = q.trim().toLowerCase();
|
||||
return cat.products.filter((p) => {
|
||||
if (bu !== "all" && p.bu !== bu) return false;
|
||||
if (domain !== "all" && p.domain !== domain) return false;
|
||||
if (state === "on" && !p.enabled) return false;
|
||||
if (state === "off" && p.enabled) return false;
|
||||
if (!needle) return true;
|
||||
return (
|
||||
p.name.toLowerCase().includes(needle) ||
|
||||
p.description.toLowerCase().includes(needle) ||
|
||||
p.replaces.some((r) => r.toLowerCase().includes(needle))
|
||||
);
|
||||
});
|
||||
}, [cat, q, bu, domain, state]);
|
||||
|
||||
const sel = cat?.products.find((p) => p.id === selId) ?? null;
|
||||
const buMap = new Map((cat?.businessUnits ?? []).map((b) => [b.id, b]));
|
||||
const domMap = new Map((cat?.domains ?? []).map((d) => [d.id, d]));
|
||||
|
||||
function addProduct() {
|
||||
if (!cat) return;
|
||||
let id = "new-product", n = 2;
|
||||
while (cat.products.some((p) => p.id === id)) id = `new-product-${n++}`;
|
||||
const p: Product = { ...BLANK, id, name: "New product", bu: cat.businessUnits[0]?.id ?? "", domain: cat.domains[0]?.id ?? "" };
|
||||
mutate((c) => { c.products.unshift(p); });
|
||||
setSelId(id);
|
||||
setQ(""); setBu("all"); setDomain("all"); setState("all");
|
||||
}
|
||||
|
||||
function exportCatalog() {
|
||||
if (!cat) return;
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(cat, null, 2)], { type: "application/json" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = "catalog.json"; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
|
||||
if (!cat) return <div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading catalog…</div>;
|
||||
|
||||
const enabledCount = cat.products.filter((p) => p.enabled).length;
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Product catalog</h1>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--text-secondary)]">
|
||||
<span className="tabnum font-semibold text-[var(--text-primary)]">{enabledCount}</span> of{" "}
|
||||
<span className="tabnum">{cat.products.length}</span> products in scope for assessments. Disabled products stay
|
||||
in the file but never appear in the mapping picker.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button className="btn" onClick={() => setShowBus((v) => !v)}>Business units</button>
|
||||
<button className="btn" onClick={exportCatalog}>Export</button>
|
||||
<button className="btn" onClick={addProduct}>+ Add product</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={!dirty || saving}>
|
||||
{saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</div>}
|
||||
|
||||
{showBus && <BuEditor cat={cat} mutate={mutate} onClose={() => setShowBus(false)} />}
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input className="field max-w-[300px]" placeholder="Search products, descriptions, competitors…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select className="field max-w-[210px]" value={bu} onChange={(e) => setBu(e.target.value)}>
|
||||
<option value="all">All business units</option>
|
||||
{cat.businessUnits.map((b) => <option key={b.id} value={b.id}>{buLabel(b)}</option>)}
|
||||
</select>
|
||||
<select className="field max-w-[230px]" value={domain} onChange={(e) => setDomain(e.target.value)}>
|
||||
<option value="all">All capability domains</option>
|
||||
{cat.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
<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)]"}`}
|
||||
onClick={() => setState(s)}>
|
||||
{s === "all" ? "All" : s === "on" ? "In scope" : "Disabled"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto tabnum text-[12.5px] text-[var(--text-muted)]">{filtered.length} shown</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_400px]">
|
||||
<div className="card overflow-hidden">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-8"><Empty>No products match those filters.</Empty></div>
|
||||
) : (
|
||||
<div className="scroll-thin max-h-[calc(100vh-260px)] overflow-y-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--surface-1)]">
|
||||
<tr className="border-b">
|
||||
<th className="w-10 py-2.5 pl-4 text-left"><span className="sr-only">In scope</span></th>
|
||||
<th className="py-2.5 text-left font-semibold">Product</th>
|
||||
<th className="w-[92px] py-2.5 text-left font-semibold">BU</th>
|
||||
<th className="w-[180px] py-2.5 pr-4 text-left font-semibold">Domain</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p) => {
|
||||
const b = buMap.get(p.bu);
|
||||
return (
|
||||
<tr key={p.id}
|
||||
onClick={() => setSelId(p.id)}
|
||||
className={`cursor-pointer border-b border-[var(--grid)] transition-colors last:border-0 ${
|
||||
selId === p.id ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"
|
||||
} ${p.enabled ? "" : "opacity-45"}`}>
|
||||
<td className="py-2 pl-4" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={p.enabled} aria-label={`${p.name} in scope`}
|
||||
onChange={(e) => mutate((c) => { c.products.find((x) => x.id === p.id)!.enabled = e.target.checked; })} />
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="truncate text-[11.5px] text-[var(--text-muted)]">{p.description}</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className="chip" style={{ borderColor: b?.color, color: b?.color }}>{b?.short ?? p.bu ?? "—"}</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-[12px] text-[var(--text-secondary)]">{domMap.get(p.domain)?.name ?? p.domain}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:sticky lg:top-[76px] lg:self-start">
|
||||
{sel ? (
|
||||
<ProductEditor key={sel.id} cat={cat} p={sel} mutate={mutate}
|
||||
onDelete={() => { mutate((c) => { c.products = c.products.filter((x) => x.id !== sel.id); }); setSelId(null); }}
|
||||
onClose={() => setSelId(null)} />
|
||||
) : (
|
||||
<div className="card p-8 text-center text-[13px] text-[var(--text-muted)]">
|
||||
Select a product to edit it, or add a new one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ sub-views */
|
||||
|
||||
function ProductEditor({
|
||||
cat, p, mutate, onDelete, onClose,
|
||||
}: {
|
||||
cat: Catalog; p: Product; mutate: (fn: (c: Catalog) => void) => void; onDelete: () => void; onClose: () => void;
|
||||
}) {
|
||||
const set = <K extends keyof Product>(k: K, v: Product[K]) =>
|
||||
mutate((c) => { const t = c.products.find((x) => x.id === p.id); if (t) (t[k] as Product[K]) = v; });
|
||||
|
||||
const idTaken = (next: string) => cat.products.some((x) => x.id === next && x.id !== p.id);
|
||||
|
||||
return (
|
||||
<div className="card fade-up flex max-h-[calc(100vh-200px)] flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b px-5 py-3">
|
||||
<h2 className="truncate text-[14px] font-semibold">{p.name || "Untitled product"}</h2>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close editor">×</button>
|
||||
</div>
|
||||
|
||||
<div className="scroll-thin flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<label className="flex items-center gap-2.5 rounded-lg bg-[var(--surface-2)] px-3 py-2.5 text-[13px] font-medium">
|
||||
<input type="checkbox" checked={p.enabled} onChange={(e) => set("enabled", e.target.checked)} />
|
||||
Include in assessments
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-name">Name</label>
|
||||
<input id="p-name" className="field" value={p.name} onChange={(e) => set("name", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label" htmlFor="p-bu">Business unit</label>
|
||||
<select id="p-bu" className="field" value={p.bu} onChange={(e) => set("bu", e.target.value)}>
|
||||
<option value="">— unassigned —</option>
|
||||
{cat.businessUnits.map((b) => <option key={b.id} value={b.id}>{buLabel(b)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="p-domain">Capability domain</label>
|
||||
<select id="p-domain" className="field" value={p.domain} onChange={(e) => set("domain", e.target.value)}>
|
||||
<option value="">— unassigned —</option>
|
||||
{cat.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-desc">Description</label>
|
||||
<textarea id="p-desc" className="field" rows={3} value={p.description} onChange={(e) => set("description", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-repl">Commonly displaces (one per line)</label>
|
||||
<textarea id="p-repl" className="field" rows={5} value={p.replaces.join("\n")}
|
||||
onChange={(e) => set("replaces", e.target.value.split("\n").map((s) => s.trim()).filter(Boolean))} />
|
||||
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
|
||||
Drives the suggested-replacement matching when a seller types a competitor tool.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-url">Reference URL</label>
|
||||
<input id="p-url" className="field" value={p.url} onChange={(e) => set("url", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="p-notes">Internal notes</label>
|
||||
<textarea id="p-notes" className="field" rows={2} value={p.notes ?? ""} onChange={(e) => set("notes", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<details className="rounded-lg border px-3 py-2">
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium text-[var(--text-secondary)]">Advanced: product id</summary>
|
||||
<input className="field mt-2" value={p.id}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||
if (!next || idTaken(next)) return;
|
||||
mutate((c) => { const t = c.products.find((x) => x.id === p.id); if (t) t.id = next; });
|
||||
}} />
|
||||
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
|
||||
Assessments reference products by id. Renaming one breaks existing mappings — avoid unless the product is unused.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-5 py-3">
|
||||
<button className="btn btn-sm btn-danger"
|
||||
onClick={() => { if (confirm(`Remove ${p.name} from the catalog?`)) onDelete(); }}>
|
||||
Delete product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdowns show the full name; fall back to the short code while the name is blank.
|
||||
function buLabel(b: BusinessUnit) {
|
||||
return b.name.trim() || b.short.trim() || "Untitled business unit";
|
||||
}
|
||||
|
||||
function BuEditor({ cat, mutate, onClose }: { cat: Catalog; mutate: (fn: (c: Catalog) => void) => void; onClose: () => void }) {
|
||||
const [addedId, setAddedId] = useState<string | null>(null);
|
||||
const counts = new Map<string, number>();
|
||||
cat.products.forEach((p) => counts.set(p.bu, (counts.get(p.bu) ?? 0) + 1));
|
||||
|
||||
return (
|
||||
<div className="card fade-up mb-4 p-5">
|
||||
<div className="mb-3 flex items-center">
|
||||
<h2 className="text-[14px] font-semibold">Business units</h2>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<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; })} />
|
||||
<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; })} />
|
||||
<input className="field w-[72px] shrink-0 py-1 text-[12.5px]" value={b.short} placeholder="Code" aria-label={`${buLabel(b)} short code`}
|
||||
onChange={(e) => mutate((c) => { c.businessUnits.find((x) => x.id === b.id)!.short = e.target.value; })} />
|
||||
<span className="shrink-0 tabnum text-[11.5px] text-[var(--text-muted)]">{counts.get(b.id) ?? 0}</span>
|
||||
<button className="btn btn-ghost btn-sm btn-danger shrink-0"
|
||||
title={counts.get(b.id) ? "Reassign its products first" : "Delete business unit"}
|
||||
disabled={!!counts.get(b.id)}
|
||||
onClick={() => mutate((c) => { c.businessUnits = c.businessUnits.filter((x) => x.id !== b.id); })}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-sm mt-3"
|
||||
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" }); });
|
||||
setAddedId(id);
|
||||
}}>
|
||||
+ Add business unit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
src/app/globals.css
Normal file
223
src/app/globals.css
Normal file
@@ -0,0 +1,223 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
: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);
|
||||
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #4a3aa7;
|
||||
--series-8: #e34948;
|
||||
|
||||
--good: #0ca30c;
|
||||
--warning: #fab219;
|
||||
--serious: #ec835a;
|
||||
--critical: #d03b3b;
|
||||
--success-text: #006300;
|
||||
|
||||
--accent: #2a78d6;
|
||||
--accent-ink: #ffffff;
|
||||
--shadow: 0 1px 2px rgba(11,11,11,.05), 0 8px 24px -12px rgba(11,11,11,.18);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--plane: #0d0d0d;
|
||||
--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);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--surface-2: #232322;
|
||||
--plane: #0d0d0d;
|
||||
--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);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-1: var(--surface-1);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-plane: var(--plane);
|
||||
--color-ink: var(--text-primary);
|
||||
--color-ink-2: var(--text-secondary);
|
||||
--color-ink-3: var(--text-muted);
|
||||
--color-line: var(--border);
|
||||
--color-line-strong: var(--border-strong);
|
||||
--color-accent: var(--accent);
|
||||
--color-good: var(--good);
|
||||
--color-warning: var(--warning);
|
||||
--color-serious: var(--serious);
|
||||
--color-critical: var(--critical);
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { border-color: var(--border); }
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
background: var(--plane);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::selection { background: color-mix(in oklab, var(--accent) 28%, transparent); }
|
||||
|
||||
/* ---- Form primitives -------------------------------------------------- */
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-primary);
|
||||
transition: border-color .12s ease, box-shadow .12s ease;
|
||||
}
|
||||
.field::placeholder { color: var(--text-muted); }
|
||||
.field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent);
|
||||
}
|
||||
.field:disabled { opacity: .5; }
|
||||
textarea.field { line-height: 1.55; resize: vertical; }
|
||||
select.field { appearance: none; background-image: none; cursor: pointer; }
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border-radius: 8px; padding: 7px 13px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-1); color: var(--text-primary);
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: background .12s ease, border-color .12s ease, transform .06s ease;
|
||||
}
|
||||
.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: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); }
|
||||
.btn-danger { color: var(--critical); }
|
||||
.btn-danger:hover { background: color-mix(in oklab, var(--critical) 12%, transparent); border-color: var(--critical); }
|
||||
.btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 11.5px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tabnum { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Range inputs get a flat, brand-consistent track in both themes. */
|
||||
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 22px; background: transparent; cursor: pointer; }
|
||||
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: var(--grid); }
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none;
|
||||
width: 15px; height: 15px; margin-top: -5.5px;
|
||||
border-radius: 50%; background: var(--accent);
|
||||
border: 2px solid var(--surface-1);
|
||||
box-shadow: 0 0 0 1px var(--border-strong);
|
||||
}
|
||||
input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 30%, transparent); }
|
||||
|
||||
input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; }
|
||||
|
||||
/* Thin, unobtrusive scrollbars inside panels. */
|
||||
.scroll-thin { scrollbar-width: thin; scrollbar-color: var(--axis) transparent; }
|
||||
.scroll-thin::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
.scroll-thin::-webkit-scrollbar-thumb { background: var(--axis); border-radius: 999px; border: 2px solid var(--surface-1); }
|
||||
.scroll-thin::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@keyframes fade-up { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||
.fade-up { animation: fade-up .18s ease both; }
|
||||
|
||||
/* ---- Print: the customer report ---------------------------------------- */
|
||||
|
||||
@media print {
|
||||
:root { --plane: #ffffff; --surface-1: #ffffff; --surface-2: #f4f3f0; }
|
||||
.no-print { display: none !important; }
|
||||
body { background: #fff; }
|
||||
.card { box-shadow: none; border-color: #d9d8d2; break-inside: avoid; }
|
||||
.page-break { break-before: page; }
|
||||
.avoid-break { break-inside: avoid; }
|
||||
@page { margin: 14mm; size: letter; }
|
||||
}
|
||||
13
src/app/icon.svg
Normal file
13
src/app/icon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<!-- 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"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="7" fill="url(#g)"/>
|
||||
<rect x="5" y="18" width="6" height="9" rx="1.5" fill="#fff" fill-opacity=".6"/>
|
||||
<rect x="13" y="12" width="6" height="15" rx="1.5" fill="#fff" fill-opacity=".8"/>
|
||||
<rect x="21" y="5" width="6" height="22" rx="1.5" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 689 B |
327
src/app/knowledge-base/page.tsx
Normal file
327
src/app/knowledge-base/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
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 { 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";
|
||||
|
||||
export default function KbPage() {
|
||||
const [kb, setKb] = useState<KnowledgeBase | null>(null);
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadError("");
|
||||
Promise.all([getJson<KnowledgeBase>("/api/knowledge-base"), getJson<Catalog>("/api/catalog")])
|
||||
.then(([base, cat]) => { if (!cancelled) { setKb(base); setCatalog(cat); } })
|
||||
.catch((e) => { if (!cancelled) setLoadError(errorMessage(e)); });
|
||||
return () => { cancelled = true; };
|
||||
}, [attempt]);
|
||||
|
||||
function mutate(fn: (k: KnowledgeBase) => void) {
|
||||
setKb((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = structuredClone(prev);
|
||||
fn(next);
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
setErr("");
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!kb) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
setKb(await sendJson<KnowledgeBase>("/api/knowledge-base", "PUT", kb));
|
||||
setDirty(false);
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!kb) return [];
|
||||
const needle = q.trim().toLowerCase();
|
||||
return kb.entries
|
||||
.filter((e) => {
|
||||
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 (!needle) return true;
|
||||
return [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase().includes(needle);
|
||||
})
|
||||
.sort((a, b) => b.observations.length - a.observations.length || a.label.localeCompare(b.label));
|
||||
}, [kb, q, filter]);
|
||||
|
||||
if (loadError) return <LoadError message={loadError} onRetry={() => setAttempt((n) => n + 1)} />;
|
||||
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 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;
|
||||
|
||||
function exportKb() {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(kb, null, 2)], { type: "application/json" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = "knowledge-base.json"; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function add() {
|
||||
const label = prompt("Competitor tool, as a seller would say it (e.g. \"Dynatrace SaaS\")");
|
||||
if (!label?.trim()) return;
|
||||
const e = newEntry(label.trim(), "manual");
|
||||
if (kb!.entries.some((x) => x.id === e.id)) { setSelId(e.id); return; }
|
||||
mutate((k) => { k.entries.unshift(e); });
|
||||
setSelId(e.id);
|
||||
setQ(""); setFilter("all");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
<div className="mb-6 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Competitive knowledge base</h1>
|
||||
<p className="mt-1 max-w-3xl text-[13.5px] text-[var(--text-secondary)]">
|
||||
<span className="tabnum font-semibold text-[var(--text-primary)]">{kb.entries.length}</span> incumbent tools,{" "}
|
||||
<span className="tabnum font-semibold text-[var(--text-primary)]">{learned}</span> refined from{" "}
|
||||
<span className="tabnum">{deals}</span> real assessment{deals === 1 ? "" : "s"}. Picking one of these in an
|
||||
assessment fills the mapping, scores and savings automatically — and every save teaches it more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<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}>
|
||||
{saving ? "Saving…" : dirty ? "Save changes" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>{err}</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) => (
|
||||
<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)]"}`}
|
||||
onClick={() => setFilter(f)}>
|
||||
{f === "all" ? "All" : f === "learned" ? "Proven in deals" : f === "seeded" ? "Not yet used" : "Unmapped"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto tabnum text-[12.5px] text-[var(--text-muted)]">{filtered.length} shown</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="card overflow-hidden">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-8"><Empty>Nothing matches those filters.</Empty></div>
|
||||
) : (
|
||||
<div className="scroll-thin max-h-[calc(100vh-270px)] overflow-y-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="sticky top-0 z-10 bg-[var(--surface-1)]">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((e) => (
|
||||
<tr key={e.id} onClick={() => setSelId(e.id)}
|
||||
className={`cursor-pointer border-b border-[var(--grid)] transition-colors last:border-0 ${selId === e.id ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"}`}>
|
||||
<td className="py-2 pl-4 pr-3">
|
||||
<div className="font-medium">{e.label}</div>
|
||||
<div className="text-[11.5px] text-[var(--text-muted)]">
|
||||
{catalog.domains.find((d) => d.id === e.domain)?.name ?? "Unassigned domain"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-[12px] text-[var(--text-secondary)]">
|
||||
{e.productIds.length
|
||||
? 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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:sticky lg:top-[76px] lg:self-start">
|
||||
{sel ? (
|
||||
<EntryEditor key={sel.id} entry={sel} catalog={catalog} mutate={mutate}
|
||||
onDelete={() => { mutate((k) => { k.entries = k.entries.filter((x) => x.id !== sel.id); }); setSelId(null); }}
|
||||
onClose={() => setSelId(null)} />
|
||||
) : (
|
||||
<div className="card p-8 text-center text-[13px] text-[var(--text-muted)]">
|
||||
Select an incumbent tool to review or curate what the tool will auto-fill.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryEditor({
|
||||
entry, catalog, mutate, onDelete, onClose,
|
||||
}: {
|
||||
entry: KbEntry; catalog: Catalog; mutate: (fn: (k: KnowledgeBase) => void) => void;
|
||||
onDelete: () => void; onClose: () => void;
|
||||
}) {
|
||||
const set = <K extends keyof KbEntry>(k: K, v: KbEntry[K]) =>
|
||||
mutate((base) => { const t = base.entries.find((x) => x.id === entry.id); if (t) (t[k] as KbEntry[K]) = v; });
|
||||
|
||||
const obs = entry.observations;
|
||||
const spend = obs.map((o) => o.annualSpend).filter((n) => n > 0);
|
||||
|
||||
return (
|
||||
<div className="card fade-up flex max-h-[calc(100vh-210px)] flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b px-5 py-3">
|
||||
<h2 className="truncate text-[14px] font-semibold">{entry.label}</h2>
|
||||
<ConfidenceChip entry={entry} />
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close editor">×</button>
|
||||
</div>
|
||||
|
||||
<div className="scroll-thin flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<label className="flex items-start gap-2.5 rounded-lg bg-[var(--surface-2)] px-3 py-2.5 text-[12.5px]">
|
||||
<input type="checkbox" className="mt-0.5" checked={entry.pinned} onChange={(e) => set("pinned", e.target.checked)} />
|
||||
<span>
|
||||
<span className="font-medium">Curate this entry</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-muted)]">
|
||||
Locks the values below so auto-learning from future assessments cannot change them.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Vendor</label>
|
||||
<input className="field" value={entry.vendor} onChange={(e) => set("vendor", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Product</label>
|
||||
<input className="field" value={entry.product} onChange={(e) => set("product", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Capability domain</label>
|
||||
<select className="field" value={entry.domain} onChange={(e) => set("domain", e.target.value)}>
|
||||
<option value="">— unassigned —</option>
|
||||
{catalog.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Our replacement</label>
|
||||
<ProductPicker catalog={catalog} value={entry.productIds} onChange={(ids) => set("productIds", ids)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Num label="Capability parity" hint={CAPABILITY_LABEL[entry.capability]} value={entry.capability} min={1} max={5} onChange={(n) => set("capability", n)} />
|
||||
<Num label="Ease of switching" hint={SWITCH_LABEL[entry.switchEase]} value={entry.switchEase} min={1} max={5} onChange={(n) => set("switchEase", n)} />
|
||||
<Num label="Time to ROI (months)" value={entry.timeToRoiMonths} min={0} max={48} onChange={(n) => set("timeToRoiMonths", n)} />
|
||||
<Num label="Spend eliminated (%)" value={entry.savingsPct} min={0} max={100} onChange={(n) => set("savingsPct", n)} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Num label="Typical migration cost" value={entry.migrationCost} min={0} step={1000} onChange={(n) => set("migrationCost", n)} />
|
||||
<div>
|
||||
<label className="label">Unit</label>
|
||||
<input className="field" placeholder="seats / cores" value={entry.unitLabel} onChange={(e) => set("unitLabel", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Known risks & dependencies</label>
|
||||
<textarea className="field" rows={3} value={entry.risks} onChange={(e) => set("risks", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Also known as (one per line)</label>
|
||||
<textarea className="field" rows={2} value={entry.aliases.join("\n")}
|
||||
onChange={(e) => set("aliases", e.target.value.split("\n").map((s) => s.trim()).filter(Boolean))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Battlecard notes</label>
|
||||
<textarea className="field" rows={3} value={entry.notes} onChange={(e) => set("notes", e.target.value)} />
|
||||
</div>
|
||||
|
||||
{obs.length > 0 && (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">
|
||||
Seen in {obs.length} assessment{obs.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
{spend.length > 0 && (
|
||||
<p className="mb-2 tabnum text-[12px] text-[var(--text-secondary)]">
|
||||
Annual spend {money(Math.min(...spend), true)} – {money(Math.max(...spend), true)}
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-1">
|
||||
{obs.slice(-6).reverse().map((o) => (
|
||||
<li key={o.sourceKey} className="flex items-baseline gap-2 text-[12px]">
|
||||
<Link href={`/assessments/${o.assessmentId}`} className="truncate font-medium hover:underline">{o.customerName}</Link>
|
||||
<span className="ml-auto shrink-0 tabnum text-[var(--text-muted)]">
|
||||
{money(o.annualSpend, true)} · {o.savingsPct}% · {o.switchEase}/5
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t px-5 py-3">
|
||||
<button className="btn btn-sm btn-danger"
|
||||
onClick={() => { if (confirm(`Remove ${entry.label} from the knowledge base?`)) onDelete(); }}>
|
||||
Delete entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({
|
||||
label, value, min, max, step = 1, hint, onChange,
|
||||
}: {
|
||||
label: string; value: number; min: number; max?: number; step?: number; hint?: string; onChange: (n: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{label}</label>
|
||||
<input className="field tabnum" type="number" value={value} min={min} max={max} step={step}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
onChange(Math.max(min, max === undefined ? n : Math.min(max, n)));
|
||||
}} />
|
||||
{hint && <div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/app/layout.tsx
Normal file
25
src/app/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Nav from "@/components/Nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Converge",
|
||||
description: "Map a customer's competitive tool landscape onto our portfolio and build the consolidation business case.",
|
||||
};
|
||||
|
||||
// The theme stamp has to land before first paint or the page flashes the wrong mode.
|
||||
const themeBoot = `try{var t=localStorage.getItem('prt-theme');if(t)document.documentElement.setAttribute('data-theme',t)}catch(e){}`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeBoot }} />
|
||||
</head>
|
||||
<body>
|
||||
<Nav />
|
||||
<main className="mx-auto w-full max-w-[1600px] px-4 pb-24 sm:px-6">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
150
src/app/page.tsx
Normal file
150
src/app/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 LoadError from "@/components/LoadError";
|
||||
import { errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [rows, setRows] = useState<AssessmentSummary[] | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoadError("");
|
||||
try {
|
||||
setRows(await getJson<AssessmentSummary[]>("/api/assessments"));
|
||||
} catch (e) {
|
||||
setLoadError(errorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/** Only navigate once the server has confirmed the new id. */
|
||||
async function open(created: Assessment) {
|
||||
if (!created?.id) throw new Error("The server did not return an assessment id.");
|
||||
router.push(`/assessments/${created.id}`);
|
||||
}
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || busy) return;
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
await open(await sendJson<Assessment>("/api/assessments", "POST", { customerName: name.trim() }));
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importFile(f: File) {
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const parsed = JSON.parse(await f.text());
|
||||
await open(await sendJson<Assessment>("/api/assessments", "POST", { import: parsed }));
|
||||
} catch (e) {
|
||||
setErr(f.name.endsWith(".json") ? errorMessage(e) : `${f.name} is not a JSON assessment file.`);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string, label: string) {
|
||||
if (!confirm(`Delete the assessment for ${label}? The JSON file is removed from data/assessments.`)) return;
|
||||
try {
|
||||
await sendJson(`/api/assessments/${id}`, "DELETE");
|
||||
load();
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
const totalSavings = (rows ?? []).reduce((s, r) => s + r.annualSavings, 0);
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
<div className="mb-7 flex flex-wrap items-end gap-6">
|
||||
<div>
|
||||
<h1 className="text-[28px] font-semibold tracking-tight">Assessments</h1>
|
||||
<p className="mt-1 max-w-2xl text-[13.5px] text-[var(--text-secondary)]">
|
||||
Capture a customer’s incumbent tool landscape, map it onto our portfolio, and build the consolidation
|
||||
business case. Every assessment is a portable JSON file under <code className="rounded bg-[var(--surface-2)] px-1 py-0.5 text-[12px]">data/assessments/</code>.
|
||||
</p>
|
||||
</div>
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="ml-auto text-right">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Pipeline savings identified</div>
|
||||
<div className="tabnum text-[22px] font-semibold" style={{ color: "var(--success-text)" }}>{money(totalSavings)}/yr</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-end gap-3">
|
||||
<form onSubmit={create} className="flex items-end gap-2">
|
||||
<div className="w-[260px]">
|
||||
<label className="label" htmlFor="new-customer">New assessment</label>
|
||||
<input id="new-customer" className="field" placeholder="Customer name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={!name.trim() || busy}>{busy ? "Working…" : "Create"}</button>
|
||||
</form>
|
||||
<button className="btn" onClick={() => fileRef.current?.click()} disabled={busy}>Import JSON</button>
|
||||
<input
|
||||
ref={fileRef} type="file" accept="application/json" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) importFile(f); e.target.value = ""; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="mb-4 rounded-xl border px-4 py-3 text-[13px]" style={{ borderColor: "var(--critical)", color: "var(--critical)" }}>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError ? (
|
||||
<LoadError message={loadError} onRetry={load} />
|
||||
) : rows === null ? (
|
||||
<div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div>
|
||||
) : rows.length === 0 ? (
|
||||
<Empty>No assessments yet — name a customer above to start one.</Empty>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="card group relative p-5 transition-shadow hover:border-[var(--text-muted)]">
|
||||
<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>
|
||||
</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="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="tabnum text-[16px] font-semibold" style={{ color: "var(--success-text)" }}>{money(r.annualSavings, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-[11.5px] text-[var(--text-muted)]">
|
||||
{r.preparedBy ? `${r.preparedBy} · ` : ""}Updated {dateLabel(r.updatedAt)}
|
||||
</div>
|
||||
</Link>
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<Link className="btn btn-ghost btn-sm" href={`/assessments/${r.id}/report`}>Report</Link>
|
||||
<button className="btn btn-ghost btn-sm btn-danger" onClick={() => remove(r.id, r.customerName)} aria-label={`Delete ${r.customerName}`}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
src/app/q/[token]/page.tsx
Normal file
463
src/app/q/[token]/page.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
"use client";
|
||||
|
||||
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PublicSurvey, SurveyAnswer, SurveyTool } from "@/lib/types";
|
||||
import { HttpError, errorMessage, getJson, sendJson } from "@/lib/client";
|
||||
import { answeredCount, parseSpend } from "@/lib/survey";
|
||||
import { money } from "@/lib/format";
|
||||
|
||||
type Answers = Record<string, SurveyAnswer>;
|
||||
type Category = PublicSurvey["categories"][number];
|
||||
|
||||
const CHIP_LIMIT = 12;
|
||||
const EMPTY: SurveyAnswer = { status: "", tools: [] };
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
const newKey = () => `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
export default function Questionnaire({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params);
|
||||
const url = `/api/q/${token}`;
|
||||
|
||||
const [pub, setPub] = useState<PublicSurvey | null>(null);
|
||||
const [answers, setAnswers] = useState<Answers>({});
|
||||
const [name, setName] = useState("");
|
||||
// -1 is the welcome screen; categories.length is the review screen.
|
||||
const [step, setStep] = useState(-1);
|
||||
const [save, setSave] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState("");
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latest = useRef({ answers, name });
|
||||
latest.current = { answers, name };
|
||||
|
||||
useEffect(() => {
|
||||
getJson<PublicSurvey>(url)
|
||||
.then((p) => {
|
||||
setPub(p); setAnswers(p.answers); setName(p.respondentName);
|
||||
// Returning respondents resume at the first category they have not answered.
|
||||
const next = p.categories.findIndex((c) => !p.answers[c.id]?.status);
|
||||
if (answeredCount({ domains: p.categories.map((c) => c.id), answers: p.answers }) > 0) {
|
||||
setStep(next === -1 ? p.categories.length : next);
|
||||
}
|
||||
})
|
||||
.catch((e) => (e instanceof HttpError && e.status === 404 ? setNotFound(true) : setLoadError(errorMessage(e))));
|
||||
}, [url]);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
||||
setSave("saving");
|
||||
try {
|
||||
await sendJson(url, "PUT", { answers: latest.current.answers, respondentName: latest.current.name });
|
||||
setSave("saved");
|
||||
} catch (e) {
|
||||
// Someone reopened or submitted from elsewhere: reload rather than fight it.
|
||||
if (e instanceof HttpError && e.status === 409) {
|
||||
const p = await getJson<PublicSurvey>(url).catch(() => null);
|
||||
if (p) { setPub(p); setAnswers(p.answers); }
|
||||
}
|
||||
setSave("error");
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
setSave("saving");
|
||||
timer.current = setTimeout(flush, 700);
|
||||
}, [flush]);
|
||||
|
||||
// Don't lose a pending edit if the tab closes mid-debounce.
|
||||
useEffect(() => {
|
||||
const onHide = () => {
|
||||
if (!timer.current) return;
|
||||
fetch(url, {
|
||||
method: "PUT", keepalive: true, headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ answers: latest.current.answers, respondentName: latest.current.name }),
|
||||
});
|
||||
};
|
||||
window.addEventListener("pagehide", onHide);
|
||||
return () => window.removeEventListener("pagehide", onHide);
|
||||
}, [url]);
|
||||
|
||||
function setAnswer(domain: string, fn: (a: SurveyAnswer) => SurveyAnswer) {
|
||||
setAnswers((prev) => ({ ...prev, [domain]: fn(prev[domain] ?? EMPTY) }));
|
||||
schedule();
|
||||
}
|
||||
|
||||
function go(n: number) {
|
||||
if (timer.current) flush();
|
||||
setStep(n);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
||||
setSubmitting(true); setSubmitError("");
|
||||
try {
|
||||
const p = await sendJson<PublicSurvey>(url, "POST", { answers, respondentName: name });
|
||||
setPub(p); setAnswers(p.answers);
|
||||
} catch (e) {
|
||||
setSubmitError(errorMessage(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (notFound) return <Shell><Centered title="Link not found">This questionnaire link is not valid or has been withdrawn. Ask whoever sent it for a new one.</Centered></Shell>;
|
||||
if (loadError) return <Shell><Centered title="Could not load the questionnaire">{loadError}<button className="btn mt-4" onClick={() => location.reload()}>Try again</button></Centered></Shell>;
|
||||
if (!pub) return <Shell><div className="card p-10 text-center text-[13px] text-[var(--text-muted)]">Loading…</div></Shell>;
|
||||
|
||||
const cats = pub.categories;
|
||||
const done = answeredCount({ domains: cats.map((c) => c.id), answers });
|
||||
const pct = cats.length ? Math.round((done / cats.length) * 100) : 0;
|
||||
|
||||
if (pub.status === "submitted") {
|
||||
return (
|
||||
<Shell customer={pub.customerName}>
|
||||
<div className="card fade-up p-8 text-center">
|
||||
<div className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-full text-[20px]" style={{ background: "color-mix(in oklab, var(--good) 16%, transparent)", color: "var(--success-text)" }}>✓</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Thank you{name ? `, ${name.split(" ")[0]}` : ""}</h1>
|
||||
<p className="mx-auto mt-2 max-w-[440px] text-[14px] text-[var(--text-secondary)]">
|
||||
Your answers have been submitted. If you need to change anything, ask the person who sent you this link to reopen it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5"><Summary cats={cats} answers={answers} /></div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
const saveText = { idle: "", saving: "Saving…", saved: "Saved", error: "Not saved — retrying on next change" }[save];
|
||||
|
||||
return (
|
||||
<Shell customer={pub.customerName} right={<span className="text-[12px]" style={{ color: save === "error" ? "var(--critical)" : "var(--text-muted)" }}>{saveText}</span>}>
|
||||
{step >= 0 && (
|
||||
<div className="mb-5">
|
||||
<div className="mb-1.5 flex text-[12px] text-[var(--text-muted)]">
|
||||
<span>{step < cats.length ? `Category ${step + 1} of ${cats.length}` : "Review"}</span>
|
||||
<span className="ml-auto tabnum">{done} of {cats.length} answered</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--grid)]">
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all duration-300" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === -1 ? (
|
||||
<Welcome pub={pub} name={name} setName={(v) => { setName(v); schedule(); }} onStart={() => go(0)} />
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[230px_minmax(0,1fr)]">
|
||||
<Rail cats={cats} answers={answers} step={step} go={go} />
|
||||
{step < cats.length ? (
|
||||
<CategoryStep
|
||||
key={cats[step].id}
|
||||
cat={cats[step]}
|
||||
answer={answers[cats[step].id] ?? EMPTY}
|
||||
onChange={(fn) => setAnswer(cats[step].id, fn)}
|
||||
onBack={() => go(step - 1)}
|
||||
onNext={() => go(step + 1)}
|
||||
isLast={step === cats.length - 1}
|
||||
/>
|
||||
) : (
|
||||
<div className="fade-up">
|
||||
<div className="card mb-4 p-5">
|
||||
<h2 className="text-[20px] font-semibold tracking-tight">Review and submit</h2>
|
||||
<p className="mt-1 text-[13.5px] text-[var(--text-secondary)]">
|
||||
{done < cats.length
|
||||
? `${cats.length - done} categor${cats.length - done === 1 ? "y is" : "ies are"} unanswered. That's fine — skipped categories are simply left blank.`
|
||||
: "Everything is answered. Check it over, then submit."}
|
||||
</p>
|
||||
<div className="mt-4 max-w-[320px]">
|
||||
<label className="label" htmlFor="q-name-review">Your name</label>
|
||||
<input id="q-name-review" className="field" value={name} placeholder="Optional"
|
||||
onChange={(e) => { setName(e.target.value); schedule(); }} />
|
||||
</div>
|
||||
</div>
|
||||
<Summary cats={cats} answers={answers} onEdit={(i) => go(i)} />
|
||||
{submitError && <p className="mt-3 text-[13px]" style={{ color: "var(--critical)" }}>{submitError}</p>}
|
||||
<div className="mt-5 flex gap-2">
|
||||
<button className="btn" onClick={() => go(cats.length - 1)}>← Back</button>
|
||||
<button className="btn btn-primary ml-auto" onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : "Submit answers"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ pieces */
|
||||
|
||||
function Shell({ children, customer, right }: { children: React.ReactNode; customer?: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[980px] pt-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[.06em] text-[var(--text-muted)]">Technology landscape</div>
|
||||
{customer && <div className="truncate text-[15px] font-semibold tracking-tight">{customer}</div>}
|
||||
</div>
|
||||
<div className="ml-auto">{right}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Centered({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card p-10 text-center">
|
||||
<h1 className="text-[17px] font-semibold">{title}</h1>
|
||||
<div className="mt-2 text-[13.5px] text-[var(--text-secondary)]">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Welcome({ pub, name, setName, onStart }: { pub: PublicSurvey; name: string; setName: (v: string) => void; onStart: () => void }) {
|
||||
const mins = Math.max(3, Math.round(pub.categories.length * 0.5));
|
||||
return (
|
||||
<div className="card fade-up p-7 sm:p-9">
|
||||
<h1 className="text-[26px] font-semibold tracking-tight">
|
||||
{pub.respondentName ? `Hi ${pub.respondentName.split(" ")[0]} — ` : ""}help us map your tool landscape
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[620px] text-[14.5px] leading-relaxed text-[var(--text-secondary)]">
|
||||
We’ll walk through <strong className="text-[var(--text-primary)]">{pub.categories.length} technology categories</strong>, one at a time.
|
||||
For each, pick the tools {pub.customerName} uses today. Annual spend and contract end dates help, but they’re optional, and so is every question.
|
||||
</p>
|
||||
<ul className="mt-5 grid max-w-[620px] gap-2 text-[13.5px] text-[var(--text-secondary)] sm:grid-cols-3">
|
||||
<li className="rounded-lg bg-[var(--surface-2)] px-3 py-2.5">⏱ About {mins} minutes</li>
|
||||
<li className="rounded-lg bg-[var(--surface-2)] px-3 py-2.5">💾 Saves as you go</li>
|
||||
<li className="rounded-lg bg-[var(--surface-2)] px-3 py-2.5">↩ Come back any time</li>
|
||||
</ul>
|
||||
<div className="mt-6 max-w-[320px]">
|
||||
<label className="label" htmlFor="q-name">Your name</label>
|
||||
<input id="q-name" className="field" value={name} placeholder="Optional" onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary mt-6 px-5 py-2.5 text-[14px]" onClick={onStart}>Start →</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rail({ cats, answers, step, go }: { cats: Category[]; answers: Answers; step: number; go: (n: number) => void }) {
|
||||
return (
|
||||
<nav className="hidden lg:block" aria-label="Categories">
|
||||
<ol className="sticky top-6 space-y-0.5">
|
||||
{cats.map((c, i) => {
|
||||
const a = answers[c.id];
|
||||
const dot = a?.status === "tools" ? "var(--accent)" : a?.status ? "var(--text-muted)" : "transparent";
|
||||
return (
|
||||
<li key={c.id}>
|
||||
<button onClick={() => go(i)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[12.5px] ${i === step ? "bg-[var(--surface-2)] font-semibold text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full border" style={{ background: dot, borderColor: a?.status ? dot : "var(--border-strong)" }} />
|
||||
<span className="truncate">{c.name}</span>
|
||||
{a?.status === "tools" && <span className="ml-auto tabnum text-[11px] text-[var(--text-muted)]">{a.tools.length}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="pt-1">
|
||||
<button onClick={() => go(cats.length)}
|
||||
className={`w-full rounded-lg px-2.5 py-1.5 text-left text-[12.5px] ${step === cats.length ? "bg-[var(--surface-2)] font-semibold" : "text-[var(--text-secondary)] hover:bg-[var(--surface-2)]"}`}>
|
||||
Review & submit
|
||||
</button>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryStep({
|
||||
cat, answer, onChange, onBack, onNext, isLast,
|
||||
}: {
|
||||
cat: Category;
|
||||
answer: SurveyAnswer;
|
||||
onChange: (fn: (a: SurveyAnswer) => SurveyAnswer) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const picked = new Set(answer.tools.map((t) => t.label.toLowerCase()));
|
||||
|
||||
const needle = q.trim().toLowerCase();
|
||||
const visible = useMemo(() => {
|
||||
const list = needle ? cat.options.filter((o) => o.label.toLowerCase().includes(needle)) : cat.options;
|
||||
return showAll || needle ? list : list.slice(0, CHIP_LIMIT);
|
||||
}, [cat.options, needle, showAll]);
|
||||
const exact = cat.options.some((o) => o.label.toLowerCase() === needle);
|
||||
|
||||
function toggle(label: string, kbEntryId: string) {
|
||||
onChange((a) => {
|
||||
const has = a.tools.find((t) => t.label.toLowerCase() === label.toLowerCase());
|
||||
const tools = has
|
||||
? a.tools.filter((t) => t !== has)
|
||||
: [...a.tools, { key: newKey(), label, kbEntryId, annualSpend: null, contractEnd: "" }];
|
||||
return { status: tools.length ? "tools" : "", tools };
|
||||
});
|
||||
}
|
||||
|
||||
function addTyped() {
|
||||
const label = q.trim().replace(/\s+/g, " ");
|
||||
if (!label) return;
|
||||
const match = cat.options.find((o) => o.label.toLowerCase() === label.toLowerCase());
|
||||
if (!picked.has(label.toLowerCase())) toggle(match?.label ?? label, match?.id ?? "");
|
||||
setQ("");
|
||||
}
|
||||
|
||||
const setTool = (key: string, patch: Partial<SurveyTool>) =>
|
||||
onChange((a) => ({ ...a, tools: a.tools.map((t) => (t.key === key ? { ...t, ...patch } : t)) }));
|
||||
|
||||
const setStatus = (status: "none" | "unsure") =>
|
||||
onChange((a) => (a.status === status ? { status: "", tools: [] } : { status, tools: [] }));
|
||||
|
||||
return (
|
||||
<div className="card fade-up p-6 sm:p-7">
|
||||
<h2 className="text-[22px] font-semibold tracking-tight">{cat.name}</h2>
|
||||
{cat.description && <p className="mt-1.5 text-[14px] text-[var(--text-secondary)]">{cat.description}</p>}
|
||||
|
||||
<p className="mt-6 text-[13.5px] font-semibold">What do you use for this today? <span className="font-normal text-[var(--text-muted)]">Pick all that apply.</span></p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{visible.map((o) => {
|
||||
const on = picked.has(o.label.toLowerCase());
|
||||
return (
|
||||
<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" }
|
||||
: { borderColor: "var(--border-strong)", color: "var(--text-primary)" }}>
|
||||
{on ? "✓ " : ""}{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!needle && cat.options.length > CHIP_LIMIT && (
|
||||
<button className="rounded-full px-3 py-1.5 text-[13px] font-medium text-[var(--accent)] hover:bg-[var(--surface-2)]" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? "Show fewer" : `+ ${cat.options.length - CHIP_LIMIT} more`}
|
||||
</button>
|
||||
)}
|
||||
{needle && visible.length === 0 && <span className="py-1.5 text-[13px] text-[var(--text-muted)]">No match in our list — add it as typed.</span>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex max-w-[480px] gap-2">
|
||||
<input className="field" placeholder="Search, or type a tool that isn't listed…" value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addTyped(); } }} />
|
||||
<button className="btn" onClick={addTyped} disabled={!q.trim() || exact && picked.has(needle)}>Add</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(["none", "unsure"] as const).map((s) => (
|
||||
<button key={s} onClick={() => setStatus(s)} aria-pressed={answer.status === s}
|
||||
className="rounded-full border px-3 py-1.5 text-[13px] font-medium"
|
||||
style={answer.status === s
|
||||
? { background: "var(--text-primary)", borderColor: "var(--text-primary)", color: "var(--plane)" }
|
||||
: { borderColor: "var(--border-strong)", color: "var(--text-secondary)" }}>
|
||||
{s === "none" ? "Nothing in place" : "Not sure"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{answer.tools.length > 0 && (
|
||||
<div className="mt-6 border-t pt-5">
|
||||
<p className="mb-3 text-[13.5px] font-semibold">Details <span className="font-normal text-[var(--text-muted)]">— optional, rough numbers are fine</span></p>
|
||||
<ul className="space-y-2.5">
|
||||
{answer.tools.map((t) => <ToolDetail key={t.key} t={t} onChange={(p) => setTool(t.key, p)} onRemove={() => toggle(t.label, t.kbEntryId)} />)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-7 flex items-center gap-2 border-t pt-5">
|
||||
<button className="btn" onClick={onBack}>← Back</button>
|
||||
<button className="btn btn-primary ml-auto" onClick={onNext}>
|
||||
{answer.status ? (isLast ? "Review →" : "Next →") : "Skip →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDetail({ t, onChange, onRemove }: { t: SurveyTool; onChange: (p: Partial<SurveyTool>) => void; onRemove: () => void }) {
|
||||
const [spendText, setSpendText] = useState(t.annualSpend === null ? "" : money(t.annualSpend));
|
||||
const [bad, setBad] = useState(false);
|
||||
const [year, month] = t.contractEnd ? t.contractEnd.split("-") : ["", ""];
|
||||
const thisYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: 8 }, (_, i) => String(thisYear - 1 + i));
|
||||
|
||||
function setEnd(y: string, m: string) {
|
||||
// Store only a complete month; a half-picked date waits for its other half.
|
||||
onChange({ contractEnd: y && m ? `${y}-${m}` : "" });
|
||||
}
|
||||
const [pendY, setPendY] = useState(year);
|
||||
const [pendM, setPendM] = useState(month);
|
||||
|
||||
return (
|
||||
<li className="grid items-end gap-3 rounded-xl border p-3.5 sm:grid-cols-[minmax(0,1fr)_170px_220px_auto]">
|
||||
<div className="min-w-0 self-center text-[14px] font-medium">{t.label}</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`s-${t.key}`}>Annual spend</label>
|
||||
<input id={`s-${t.key}`} className="field tabnum" inputMode="decimal" placeholder="e.g. 250k" value={spendText}
|
||||
style={bad ? { borderColor: "var(--critical)" } : undefined}
|
||||
onChange={(e) => {
|
||||
setSpendText(e.target.value);
|
||||
const v = parseSpend(e.target.value);
|
||||
setBad(!!e.target.value.trim() && v === null);
|
||||
if (v !== null || !e.target.value.trim()) onChange({ annualSpend: v });
|
||||
}}
|
||||
onBlur={() => { if (t.annualSpend !== null && !bad) setSpendText(money(t.annualSpend)); }} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="label">Contract ends</span>
|
||||
<div className="flex gap-1.5">
|
||||
<select className="field" aria-label={`${t.label} contract end month`} value={pendM}
|
||||
onChange={(e) => { setPendM(e.target.value); setEnd(pendY, e.target.value); }}>
|
||||
<option value="">Month</option>
|
||||
{MONTHS.map((m, i) => <option key={m} value={String(i + 1).padStart(2, "0")}>{m}</option>)}
|
||||
</select>
|
||||
<select className="field" aria-label={`${t.label} contract end year`} value={pendY}
|
||||
onChange={(e) => { setPendY(e.target.value); setEnd(e.target.value, pendM); }}>
|
||||
<option value="">Year</option>
|
||||
{(years.includes(pendY) || !pendY ? years : [pendY, ...years]).map((y) => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm self-center justify-self-end" onClick={onRemove} aria-label={`Remove ${t.label}`}>Remove</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ cats, answers, onEdit }: { cats: Category[]; answers: Answers; onEdit?: (i: number) => void }) {
|
||||
return (
|
||||
<div className="card divide-y">
|
||||
{cats.map((c, i) => {
|
||||
const a = answers[c.id] ?? EMPTY;
|
||||
return (
|
||||
<div key={c.id} className="flex items-start gap-3 px-5 py-3">
|
||||
<div className="w-[38%] shrink-0 text-[13px] font-medium">{c.name}</div>
|
||||
<div className="min-w-0 flex-1 text-[13px] text-[var(--text-secondary)]">
|
||||
{a.status === "tools" ? (
|
||||
<ul className="space-y-0.5">
|
||||
{a.tools.map((t) => (
|
||||
<li key={t.key}>
|
||||
<span className="text-[var(--text-primary)]">{t.label}</span>
|
||||
{(t.annualSpend !== null || t.contractEnd) && (
|
||||
<span className="text-[var(--text-muted)]">
|
||||
{" · "}{[t.annualSpend !== null ? `${money(t.annualSpend, true)}/yr` : "", t.contractEnd ? `ends ${MONTHS[+t.contractEnd.slice(5) - 1]} ${t.contractEnd.slice(0, 4)}` : ""].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : a.status === "none" ? "Nothing in place" : a.status === "unsure" ? "Not sure" : <span className="text-[var(--text-muted)]">Skipped</span>}
|
||||
</div>
|
||||
{onEdit && <button className="btn btn-ghost btn-sm shrink-0" onClick={() => onEdit(i)}>Edit</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
src/components/AccountIntelStrip.tsx
Normal file
130
src/components/AccountIntelStrip.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { AccountIntel } from "@/lib/types";
|
||||
import { dateLabel, num } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Internal-only context from the account dossier.
|
||||
*
|
||||
* Marked `no-print` and never referenced by the customer report: the source
|
||||
* document mixes customer-safe facts with internal pricing strategy, competitive
|
||||
* risk and personal details.
|
||||
*/
|
||||
export default function AccountIntelStrip({ intel, onClear }: { intel: AccountIntel; onClear: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const headline = [
|
||||
["Classification", intel.classification],
|
||||
["Status", intel.accountStatus],
|
||||
["Sentiment", intel.sentiment],
|
||||
["Renewal", intel.renewalTimeframe],
|
||||
["Renewal risk", intel.renewalRisk],
|
||||
["Deployment", intel.deploymentRate],
|
||||
].filter(([, v]) => v) as [string, string][];
|
||||
|
||||
const shelfware = intel.consumption.filter((c) => c.entitled > 0 && c.deployed === 0);
|
||||
|
||||
return (
|
||||
<section className="no-print card mb-5 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
|
||||
<span className="chip" style={{ borderColor: "var(--warning)", color: "var(--warning)" }}>Internal only</span>
|
||||
<span className="text-[13px] font-semibold">Account intelligence</span>
|
||||
{intel.sourceUrl && (
|
||||
<a className="max-w-[280px] truncate text-[12px] text-[var(--accent)] hover:underline"
|
||||
href={intel.sourceUrl} target="_blank" rel="noreferrer noopener">
|
||||
{intel.sourceTitle || "Open dossier"}
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[11.5px] text-[var(--text-muted)]">Imported {dateLabel(intel.importedAt)}</span>
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setOpen((v) => !v)}>{open ? "Hide" : "Details"}</button>
|
||||
<button className="btn btn-ghost btn-sm btn-danger"
|
||||
onClick={() => { if (confirm("Remove the imported account intelligence from this assessment?")) onClear(); }}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headline.length > 0 && (
|
||||
<dl className="flex flex-wrap gap-x-6 gap-y-2 border-t px-4 py-2.5">
|
||||
{headline.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{k}</dt>
|
||||
<dd className="text-[12.5px]">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="fade-up grid gap-4 border-t p-4 lg:grid-cols-3">
|
||||
{shelfware.length > 0 && (
|
||||
<Panel title={`Entitled, not deployed (${shelfware.length})`}
|
||||
hint="Already paid for — adoption plays that need no new licence spend.">
|
||||
<ul className="space-y-1">
|
||||
{shelfware.map((s) => (
|
||||
<li key={s.product} className="flex items-baseline gap-2 text-[12.5px]">
|
||||
<span className="truncate font-medium">{s.product}</span>
|
||||
<span className="ml-auto shrink-0 tabnum text-[var(--text-muted)]">{num(s.entitled)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{intel.infrastructure.length > 0 && (
|
||||
<Panel title="Infrastructure estate" hint="The vendor per layer, as the dossier records it.">
|
||||
<ul className="space-y-1">
|
||||
{[...intel.infrastructure, ...intel.cloud].map((f) => (
|
||||
<li key={f.label} className="flex items-baseline gap-2 text-[12.5px]">
|
||||
<span className="shrink-0 text-[var(--text-muted)]">{f.label}</span>
|
||||
<span className="ml-auto truncate text-right font-medium">{f.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{intel.applications.length > 0 && (
|
||||
<Panel title={`Application estate (${intel.applications.length})`} hint="Grouped as the dossier groups them.">
|
||||
<ul className="scroll-thin max-h-[190px] space-y-1 overflow-y-auto">
|
||||
{intel.applications.map((app) => (
|
||||
<li key={`${app.category}-${app.name}`} className="flex items-baseline gap-2 text-[12.5px]">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-[var(--text-muted)]">{app.category}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{intel.narratives.length > 0 && (
|
||||
<div className="lg:col-span-3">
|
||||
<Panel title="Analyst assessment" hint="Background for the executive summary — not customer-facing copy.">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{intel.narratives.map((n) => (
|
||||
<div key={n.heading}>
|
||||
<div className="text-[12px] font-semibold">{n.heading}</div>
|
||||
<p className="mt-0.5 text-[12px] leading-relaxed text-[var(--text-secondary)]">{n.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, hint, children }: { title: string; hint: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-[var(--surface-2)] p-3">
|
||||
<h4 className="text-[12px] font-semibold">{title}</h4>
|
||||
<p className="mb-2 text-[11px] text-[var(--text-muted)]">{hint}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
src/components/Analysis.tsx
Normal file
306
src/components/Analysis.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Assessment, Catalog } from "@/lib/types";
|
||||
import type { Rollup } from "@/lib/scoring";
|
||||
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";
|
||||
|
||||
/* -------------------------------------------------------- tool footprint */
|
||||
|
||||
export function ToolFootprintTile({ r }: { r: Rollup }) {
|
||||
const cut = r.toolsRetired - r.toolsAfter;
|
||||
const pct = r.toolsRetired ? Math.round((cut / r.toolsRetired) * 100) : 0;
|
||||
return (
|
||||
<StatTile
|
||||
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` : ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- value by business unit */
|
||||
|
||||
export function BuBreakdown({ r, catalog, horizonYears }: { r: Rollup; catalog: Catalog; horizonYears: number }) {
|
||||
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
|
||||
const total = r.byBu.reduce((s, b) => s + b.savings, 0);
|
||||
const max = Math.max(1, ...r.byBu.map((b) => b.savings));
|
||||
|
||||
return (
|
||||
<ChartFrame
|
||||
title="Value by business unit"
|
||||
subtitle={`Where the displaced spend lands in our portfolio. A tool mapped to two units splits its value between them.`}
|
||||
table={{
|
||||
headers: ["Business unit", "Tools displaced", "Our products", "Current spend", "Annual saving", "Migration", `${horizonYears}-yr net`],
|
||||
rows: r.byBu.map((b) => [b.name, b.count, b.productIds.length, money(b.spend), money(b.savings), money(b.migrationCost), money(b.horizonNet)]),
|
||||
}}
|
||||
>
|
||||
{r.byBu.length === 0 ? <Empty>Map replacement products to see this.</Empty> : (
|
||||
<>
|
||||
{/* share of annual saving, as one stacked bar */}
|
||||
{total > 0 && (
|
||||
<div className="mb-4 flex h-2.5 w-full overflow-hidden rounded-full bg-[var(--grid)]" role="img"
|
||||
aria-label={r.byBu.map((b) => `${b.short} ${Math.round((b.savings / total) * 100)}%`).join(", ")}>
|
||||
{r.byBu.filter((b) => b.savings > 0).map((b) => (
|
||||
<div key={b.buId} style={{ width: `${(b.savings / total) * 100}%`, background: b.color }}
|
||||
title={`${b.name}: ${Math.round((b.savings / total) * 100)}% of annual saving`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-[var(--grid)]">
|
||||
{r.byBu.map((b) => (
|
||||
<li key={b.buId} className="grid items-center gap-x-4 gap-y-1 py-2.5 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_repeat(3,96px)]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: b.color }} />
|
||||
<span className="truncate text-[13px] font-semibold">{b.name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate pl-[18px] text-[11.5px] text-[var(--text-muted)]" title={b.productIds.map(prodName).join(", ")}>
|
||||
{b.count} tool{b.count === 1 ? "" : "s"} → {b.productIds.map(prodName).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--grid)]">
|
||||
<div className="h-full rounded-full" style={{ width: `${Math.max(1.5, (b.savings / max) * 100)}%`, background: b.color }} />
|
||||
</div>
|
||||
<Num label="Spend" v={money(b.spend, true)} />
|
||||
<Num label="Saving / yr" v={money(b.savings, true)} strong />
|
||||
<Num label={`${horizonYears}-yr net`} v={money(b.horizonNet, true)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ label, v, strong }: { label: string; v: string; strong?: boolean }) {
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
|
||||
<div className={`tabnum text-[13px] ${strong ? "font-semibold" : ""}`} style={strong ? { color: "var(--success-text)" } : undefined}>{v}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- implementation timeline */
|
||||
|
||||
const PHASE_MONTHS = 6;
|
||||
|
||||
export function ImplementationTimeline({
|
||||
a, r, catalog, onParallel,
|
||||
}: {
|
||||
a: Assessment;
|
||||
r: Rollup;
|
||||
catalog: Catalog;
|
||||
/** Workbench only: lets the seller tune how many migrations run at once. */
|
||||
onParallel?: (n: number) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [asTable, setAsTable] = useState(false);
|
||||
const plan = useMemo(() => buildPlan(a, r, catalog), [a, r, catalog]);
|
||||
const L = (n: number, long = false) => monthLabel(plan.startIso, n, long);
|
||||
|
||||
const summary = plan.items.length
|
||||
? `${plan.items.length} projects · ${L(0)} – ${L(plan.completion - 1)} · full run-rate savings from ${L(plan.fullRunRate)}`
|
||||
: "Map replacement products to build a plan.";
|
||||
|
||||
return (
|
||||
<section className="card avoid-break mb-5 overflow-hidden">
|
||||
<button className="flex w-full items-center gap-3 px-5 py-4 text-left hover:bg-[var(--surface-2)]"
|
||||
aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||||
<span className="text-[12px] text-[var(--text-muted)] transition-transform no-print" style={{ transform: open ? "rotate(90deg)" : "none" }}>▶</span>
|
||||
<span className="min-w-0">
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapsed on screen, but always part of the printed report. */}
|
||||
<div className={open ? "border-t p-5" : "hidden border-t p-5 print:block"}>
|
||||
{plan.items.length === 0 ? <Empty>Map replacement products to build a plan.</Empty> : (
|
||||
<>
|
||||
<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="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>
|
||||
|
||||
<div className="no-print mb-3 flex flex-wrap items-center gap-2">
|
||||
{onParallel && (
|
||||
<>
|
||||
<span className="text-[12px] text-[var(--text-secondary)]">Parallel migrations per business unit</span>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<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} />}
|
||||
|
||||
<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.
|
||||
{plan.unmappedCount > 0 && ` ${plan.unmappedCount} unmapped tool${plan.unmappedCount === 1 ? " is" : "s are"} left out until a replacement is chosen.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Mini({ label, value, sub }: { label: string; value: string; sub: string }) {
|
||||
return (
|
||||
<div className="rounded-xl bg-[var(--surface-2)] px-3.5 py-3">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
|
||||
<div className="mt-1 text-[18px] font-semibold tracking-tight">{value}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-secondary)]">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gantt({ plan, catalog }: { plan: Plan; catalog: Catalog }) {
|
||||
const span = plan.span;
|
||||
const pct = (m: number) => `${(Math.min(Math.max(m, 0), span) / span) * 100}%`;
|
||||
|
||||
// Quarter ticks, labelled at each quarter boundary inside the span.
|
||||
const ticks: number[] = [];
|
||||
const firstMonth = new Date(`${plan.startIso}T00:00:00Z`).getUTCMonth();
|
||||
for (let m = 0; m <= span; m++) if ((firstMonth + m) % 3 === 0) ticks.push(m);
|
||||
|
||||
const phases = new Map<number, PlanItem[]>();
|
||||
for (const i of plan.items) {
|
||||
const p = Math.floor(i.start / PHASE_MONTHS);
|
||||
phases.set(p, [...(phases.get(p) ?? []), i]);
|
||||
}
|
||||
|
||||
const buLegend = catalog.businessUnits.filter((b) => plan.items.some((i) => i.color === b.color));
|
||||
const peak = Math.max(1, ...plan.ramp);
|
||||
|
||||
const Track = ({ children, className = "" }: { children?: React.ReactNode; className?: string }) => (
|
||||
<div 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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scroll-thin overflow-x-auto">
|
||||
<div className="min-w-[760px]">
|
||||
{/* 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>
|
||||
<div className="relative h-4">
|
||||
{ticks.map((m) => (
|
||||
<span key={m} className="absolute -translate-x-1/2 whitespace-nowrap text-[10.5px] tabnum text-[var(--text-muted)]" style={{ left: pct(m) }}>
|
||||
{quarterLabel(plan.startIso, m)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{[...phases.entries()].map(([p, items]) => (
|
||||
<div key={p}>
|
||||
<div className="grid grid-cols-[240px_minmax(0,1fr)] bg-[var(--surface-2)]">
|
||||
<div className="px-1 py-1.5 text-[11px] font-semibold">
|
||||
Phase {p + 1} <span className="font-normal text-[var(--text-muted)]">· starts {monthLabel(plan.startIso, p * PHASE_MONTHS)} – {monthLabel(plan.startIso, p * PHASE_MONTHS + PHASE_MONTHS - 1)}</span>
|
||||
</div>
|
||||
<div className="self-center pr-1 text-right text-[11px] tabnum text-[var(--text-secondary)]">
|
||||
{items.length} project{items.length === 1 ? "" : "s"} · {money(items.reduce((s, i) => s + i.annualSavings, 0), true)}/yr unlocked
|
||||
</div>
|
||||
</div>
|
||||
{items.map((i) => (
|
||||
<div key={i.id} className="grid grid-cols-[240px_minmax(0,1fr)] border-b border-[var(--grid)]">
|
||||
<div className="min-w-0 py-1.5 pr-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[12.5px] font-medium">{i.label}</span>
|
||||
{i.bridge && <span className="shrink-0 text-[11px]" style={{ color: "var(--warning)" }} title="Contract ends before this migration can finish — plan a short bridge extension">⚠</span>}
|
||||
</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 }} />
|
||||
)}
|
||||
<div className="absolute top-1/2 flex h-4 -translate-y-1/2 items-center rounded-[5px]"
|
||||
title={`${i.label}: ${monthLabel(plan.startIso, i.start)} → cutover ${monthLabel(plan.startIso, i.end - 1)} · ${money(i.annualSavings)}/yr from ${monthLabel(plan.startIso, i.savingsFrom)}`}
|
||||
style={{
|
||||
left: pct(i.start), width: `calc(${pct(i.end)} - ${pct(i.start)})`, background: i.color,
|
||||
...(i.bridge ? { backgroundImage: "repeating-linear-gradient(45deg, transparent 0 5px, rgba(255,255,255,.35) 5px 8px)" } : {}),
|
||||
}} />
|
||||
{i.annualSavings > 0 && (
|
||||
<span className="absolute top-1/2 ml-1.5 -translate-y-1/2 whitespace-nowrap text-[10.5px] tabnum text-[var(--text-secondary)]"
|
||||
style={{ left: pct(Math.max(i.end, i.savingsFrom)) }}>{money(i.annualSavings, true)}/yr</span>
|
||||
)}
|
||||
{i.contractEnd !== null && (
|
||||
<span className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 border-2"
|
||||
style={{ left: pct(i.contractEnd + 1), borderColor: "var(--text-primary)", background: "var(--surface-1)" }}
|
||||
title={`Contract ends ${monthLabel(plan.startIso, i.contractEnd, true)}`} />
|
||||
)}
|
||||
</Track>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* run-rate savings ramp */}
|
||||
<div className="grid grid-cols-[240px_minmax(0,1fr)] pt-2">
|
||||
<div className="pr-3 text-[11px]">
|
||||
<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">
|
||||
<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`}
|
||||
fill="var(--good)" fillOpacity=".18" stroke="var(--success-text)" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
|
||||
</svg>
|
||||
</Track>
|
||||
</div>
|
||||
|
||||
{/* legend */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] text-[var(--text-secondary)]">
|
||||
{buLegend.map((b) => (
|
||||
<span key={b.id} className="flex items-center gap-1.5"><span className="h-2.5 w-4 rounded-sm" style={{ background: b.color }} />{b.short}</span>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
</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"]}
|
||||
rows={plan.items.map((i) => [
|
||||
`${i.label}${i.bridge ? " ⚠" : ""}`, i.products, L(i.start), L(i.end - 1),
|
||||
i.contractEnd === null ? "—" : L(i.contractEnd), L(i.savingsFrom), money(i.annualSavings),
|
||||
])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
228
src/components/CandidateRow.tsx
Normal file
228
src/components/CandidateRow.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Candidate, Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
|
||||
import type { CandidateMetrics } from "@/lib/scoring";
|
||||
import { CAPABILITY_LABEL, SWITCH_LABEL, money, monthsLabel } from "@/lib/format";
|
||||
import ProductPicker, { suggest } from "./ProductPicker";
|
||||
import IncumbentPicker, { ConfidenceChip } from "./IncumbentPicker";
|
||||
|
||||
const WAVE_COLOR = ["var(--series-1)", "var(--series-2)", "var(--series-3)"];
|
||||
|
||||
function Slider({
|
||||
id, label, value, min, max, step = 1, hint, onChange, suffix,
|
||||
}: {
|
||||
id: string; label: string; value: number; min: number; max: number; step?: number;
|
||||
hint?: string; suffix?: string; onChange: (n: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline gap-2">
|
||||
<label className="label mb-0" htmlFor={id}>{label}</label>
|
||||
<span className="ml-auto tabnum text-[12.5px] font-semibold">{value}{suffix}</span>
|
||||
</div>
|
||||
<input id={id} type="range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||
{hint && <div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CandidateRow({
|
||||
c, m, catalog, kb, onChange, onDelete, onDuplicate,
|
||||
}: {
|
||||
c: Candidate; m: CandidateMetrics; catalog: Catalog; kb: KnowledgeBase | null;
|
||||
onChange: (next: Candidate) => void; onDelete: () => void; onDuplicate: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!c.incumbentProduct);
|
||||
const [applied, setApplied] = useState<KbEntry | null>(null);
|
||||
const set = <K extends keyof Candidate>(k: K, v: Candidate[K]) => onChange({ ...c, [k]: v });
|
||||
|
||||
/** Applying an entry fills the row in one write so autosave sees a single change. */
|
||||
function applyEntry(e: KbEntry) {
|
||||
onChange({
|
||||
...c,
|
||||
incumbentVendor: e.vendor,
|
||||
incumbentProduct: e.product,
|
||||
domain: e.domain || c.domain,
|
||||
productIds: e.productIds.length ? [...e.productIds] : c.productIds,
|
||||
capability: e.capability,
|
||||
switchEase: e.switchEase,
|
||||
timeToRoiMonths: e.timeToRoiMonths,
|
||||
savingsPct: e.savingsPct,
|
||||
// Spend and quantity are always customer-specific, so they are never prefilled.
|
||||
migrationCost: e.migrationCost || c.migrationCost,
|
||||
unitLabel: e.unitLabel || c.unitLabel,
|
||||
risks: c.risks.trim() || e.risks,
|
||||
});
|
||||
setApplied(e);
|
||||
}
|
||||
|
||||
const suggestions = suggest(catalog, `${c.incumbentVendor} ${c.incumbentProduct}`, c.domain)
|
||||
.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";
|
||||
|
||||
return (
|
||||
<div className={`card avoid-break overflow-hidden transition-opacity ${c.included ? "" : "opacity-55"}`}>
|
||||
<div className="flex cursor-pointer items-center gap-3 px-4 py-3 hover:bg-[var(--surface-2)]"
|
||||
onClick={() => setOpen((v) => !v)}>
|
||||
<span className="w-1.5 self-stretch rounded-full" style={{ background: WAVE_COLOR[m.wave - 1] }} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-[var(--text-muted)]">
|
||||
{mapped.length ? `→ ${mapped.map((p) => p!.name).join(", ")}` : "No replacement mapped yet"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Saving</div>
|
||||
<div className="tabnum text-[13px] font-semibold" style={{ color: "var(--success-text)" }}>{money(m.annualSavings, true)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Payback</div>
|
||||
<div className="tabnum text-[13px] font-semibold">{monthsLabel(m.paybackMonths)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
<span className="tabnum rounded-lg px-2 py-1 text-[13px] font-semibold"
|
||||
style={{ background: `color-mix(in oklab, ${WAVE_COLOR[m.wave - 1]} 16%, transparent)`, color: WAVE_COLOR[m.wave - 1] }}
|
||||
title={`Rationalization score ${m.score.toFixed(0)} of 100 — Wave ${m.wave}`}>
|
||||
{m.score.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-[var(--text-muted)]">{open ? "▴" : "▾"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fade-up border-t px-4 py-4">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* ---- Incumbent ---- */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Incumbent tool</h4>
|
||||
<IncumbentPicker
|
||||
kb={kb} catalog={catalog}
|
||||
vendor={c.incumbentVendor} product={c.incumbentProduct}
|
||||
onType={(field, value) => { setApplied(null); set(field, value); }}
|
||||
onPick={applyEntry}
|
||||
/>
|
||||
{applied && (
|
||||
<div className="fade-up flex items-center gap-2 rounded-lg bg-[var(--surface-2)] px-2.5 py-2 text-[11.5px] text-[var(--text-secondary)]">
|
||||
<ConfidenceChip entry={applied} />
|
||||
<span>Filled from the knowledge base. Adjust anything — your values are learned back.</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label" htmlFor={`d-${c.id}`}>Capability domain</label>
|
||||
<select id={`d-${c.id}`} className="field" value={c.domain} onChange={(e) => set("domain", e.target.value)}>
|
||||
<option value="">— choose —</option>
|
||||
{catalog.domains.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label" htmlFor={`s-${c.id}`}>Annual spend (USD)</label>
|
||||
<input id={`s-${c.id}`} className="field tabnum" type="number" min={0} step={1000} value={c.annualSpend} onChange={(e) => set("annualSpend", Math.max(0, Number(e.target.value)))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`u-${c.id}`}>Quantity</label>
|
||||
<input id={`u-${c.id}`} className="field tabnum" type="number" min={0} value={c.units} onChange={(e) => set("units", Math.max(0, Number(e.target.value)))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`ul-${c.id}`}>Unit</label>
|
||||
<input id={`ul-${c.id}`} className="field" placeholder="seats / cores" value={c.unitLabel} onChange={(e) => set("unitLabel", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`r-${c.id}`}>Contract renewal</label>
|
||||
<input id={`r-${c.id}`} className="field" type="date" value={c.renewalDate} onChange={(e) => set("renewalDate", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Replacement ---- */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Our replacement</h4>
|
||||
<ProductPicker catalog={catalog} value={c.productIds} onChange={(ids) => set("productIds", ids)} />
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="rounded-lg bg-[var(--surface-2)] p-2.5">
|
||||
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">Suggested</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{suggestions.map((p) => (
|
||||
<button key={p.id} className="btn btn-sm" title={p.description}
|
||||
onClick={() => set("productIds", [...c.productIds, p.id])}>+ {p.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label" htmlFor={`sp-${c.id}`}>Spend eliminated</label>
|
||||
<input id={`sp-${c.id}`} className="field tabnum" type="number" min={0} max={100} value={c.savingsPct}
|
||||
onChange={(e) => set("savingsPct", Math.min(100, Math.max(0, Number(e.target.value))))} />
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">% of the {money(c.annualSpend, true)} line</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`mc-${c.id}`}>Migration cost (USD)</label>
|
||||
<input id={`mc-${c.id}`} className="field tabnum" type="number" min={0} step={1000} value={c.migrationCost}
|
||||
onChange={(e) => set("migrationCost", Math.max(0, Number(e.target.value)))} />
|
||||
<div className="mt-0.5 text-[11.5px] text-[var(--text-muted)]">One-time services + effort</div>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
hint={CAPABILITY_LABEL[c.capability]} onChange={(n) => set("capability", n)} />
|
||||
<Slider id={`se-${c.id}`} label="Ease of switching" value={c.switchEase} min={1} max={5}
|
||||
hint={SWITCH_LABEL[c.switchEase]} onChange={(n) => set("switchEase", n)} />
|
||||
<Slider id={`roi-${c.id}`} label="Time to ROI" value={c.timeToRoiMonths} min={0} max={48} suffix=" mo"
|
||||
hint={c.timeToRoiMonths <= 6 ? "Fast — scores full marks" : c.timeToRoiMonths >= 36 ? "Beyond the scoring horizon" : "Within the scoring horizon"}
|
||||
onChange={(n) => set("timeToRoiMonths", n)} />
|
||||
<Slider id={`sv-${c.id}`} label="Strategic value" value={c.strategicValue} min={1} max={5}
|
||||
hint="Non-financial importance to the customer" onChange={(n) => set("strategicValue", n)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label" htmlFor={`ri-${c.id}`}>Risks & dependencies</label>
|
||||
<textarea id={`ri-${c.id}`} className="field" rows={2} placeholder="Integrations, skills, contract lock-in…"
|
||||
value={c.risks} onChange={(e) => set("risks", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor={`no-${c.id}`}>Internal notes</label>
|
||||
<textarea id={`no-${c.id}`} className="field" rows={2} placeholder="Not shown in the customer report"
|
||||
value={c.notes} onChange={(e) => set("notes", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<label className="flex items-center gap-2 text-[12.5px] font-medium">
|
||||
<input type="checkbox" checked={c.included} onChange={(e) => set("included", e.target.checked)} />
|
||||
Include in totals and the customer report
|
||||
</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
|
||||
{m.roiPct !== null && ` · ${m.roiPct.toFixed(0)}% ROI`}
|
||||
</span>
|
||||
<button className="btn btn-sm" onClick={onDuplicate}>Duplicate</button>
|
||||
<button className="btn btn-sm btn-danger" onClick={() => { if (confirm(`Remove ${title}?`)) onDelete(); }}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
src/components/IncumbentPicker.tsx
Normal file
119
src/components/IncumbentPicker.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Catalog, KbEntry, KnowledgeBase } from "@/lib/types";
|
||||
import { searchKb } from "@/lib/kb";
|
||||
|
||||
/**
|
||||
* Typeahead over the competitive knowledge base.
|
||||
*
|
||||
* Typing filters known incumbent tools; picking one applies its learned
|
||||
* defaults — product mapping, scores, savings and migration cost — in one go.
|
||||
*/
|
||||
export default function IncumbentPicker({
|
||||
kb, catalog, vendor, product, onType, onPick,
|
||||
}: {
|
||||
kb: KnowledgeBase | null;
|
||||
catalog: Catalog;
|
||||
vendor: string;
|
||||
product: string;
|
||||
onType: (field: "incumbentVendor" | "incumbentProduct", value: string) => void;
|
||||
onPick: (entry: KbEntry) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focus, setFocus] = useState<"vendor" | "product" | null>(null);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const query = [vendor, product].filter(Boolean).join(" ");
|
||||
const matches = useMemo(() => (kb ? searchKb(kb, query, 8) : []), [kb, query]);
|
||||
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
useEffect(() => { setCursor(0); }, [query]);
|
||||
|
||||
function choose(e: KbEntry) {
|
||||
onPick(e);
|
||||
setOpen(false);
|
||||
setFocus(null);
|
||||
}
|
||||
|
||||
function onKeyDown(ev: React.KeyboardEvent) {
|
||||
if (!open || matches.length === 0) return;
|
||||
if (ev.key === "ArrowDown") { ev.preventDefault(); setCursor((c) => (c + 1) % matches.length); }
|
||||
else if (ev.key === "ArrowUp") { ev.preventDefault(); setCursor((c) => (c - 1 + matches.length) % matches.length); }
|
||||
else if (ev.key === "Enter") { ev.preventDefault(); choose(matches[cursor]); }
|
||||
else if (ev.key === "Escape") { setOpen(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={boxRef}
|
||||
onBlur={(e) => { if (!boxRef.current?.contains(e.relatedTarget as Node)) setOpen(false); }}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label" htmlFor={`v-${vendor}-${product}`}>Vendor</label>
|
||||
<input
|
||||
className="field" placeholder="e.g. Splunk" value={vendor}
|
||||
aria-label="Incumbent vendor" autoComplete="off"
|
||||
onChange={(e) => { onType("incumbentVendor", e.target.value); setOpen(true); }}
|
||||
onFocus={() => { setFocus("vendor"); setOpen(true); }}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Product</label>
|
||||
<input
|
||||
className="field" placeholder="e.g. Enterprise" value={product}
|
||||
aria-label="Incumbent product" autoComplete="off"
|
||||
onChange={(e) => { onType("incumbentProduct", e.target.value); setOpen(true); }}
|
||||
onFocus={() => { setFocus("product"); setOpen(true); }}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && focus && matches.length > 0 && (
|
||||
<div className="absolute z-30 mt-1.5 w-full rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-1.5 shadow-[var(--shadow)]">
|
||||
<div className="px-2 pb-1 pt-0.5 text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">
|
||||
Known incumbents — picking one fills the whole row
|
||||
</div>
|
||||
<ul className="scroll-thin max-h-[290px] overflow-y-auto">
|
||||
{matches.map((e, i) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left ${i === cursor ? "bg-[var(--surface-2)]" : "hover:bg-[var(--surface-2)]"}`}
|
||||
onMouseEnter={() => setCursor(i)}
|
||||
onClick={() => choose(e)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium">{e.label}</span>
|
||||
<span className="block truncate text-[11.5px] text-[var(--text-muted)]">
|
||||
{e.productIds.length ? `→ ${e.productIds.map(prodName).join(", ")}` : "No mapping yet"}
|
||||
</span>
|
||||
</span>
|
||||
<ConfidenceChip entry={e} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfidenceChip({ entry }: { entry: KbEntry }) {
|
||||
const n = entry.observations.length;
|
||||
if (entry.pinned) {
|
||||
return <span className="chip shrink-0" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }} title="Curated entry — auto-learning will not overwrite it">Curated</span>;
|
||||
}
|
||||
if (n === 0) {
|
||||
return <span className="chip shrink-0" title="Seeded from the product catalog; not yet used in a deal">Seeded</span>;
|
||||
}
|
||||
return (
|
||||
<span className="chip shrink-0" style={{ borderColor: "var(--accent)", color: "var(--accent)" }}
|
||||
title={`Learned from ${n} assessment${n === 1 ? "" : "s"}`}>
|
||||
{n} deal{n === 1 ? "" : "s"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
198
src/components/IntelImport.tsx
Normal file
198
src/components/IntelImport.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Catalog, IntelImportResult, IntelProposal } from "@/lib/types";
|
||||
import { errorMessage, sendJson } from "@/lib/client";
|
||||
import { num } from "@/lib/format";
|
||||
|
||||
const CONFIDENCE_STYLE: Record<IntelProposal["confidence"], { color: string; label: string }> = {
|
||||
high: { color: "var(--success-text)", label: "High" },
|
||||
medium: { color: "var(--warning)", label: "Medium" },
|
||||
low: { color: "var(--text-muted)", label: "Low" },
|
||||
};
|
||||
|
||||
export default function IntelImport({
|
||||
catalog, onClose, onAccept,
|
||||
}: {
|
||||
catalog: Catalog;
|
||||
onClose: () => void;
|
||||
onAccept: (result: IntelImportResult, chosen: IntelProposal[]) => void;
|
||||
}) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [result, setResult] = useState<IntelImportResult | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
|
||||
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
async function analyse() {
|
||||
setBusy(true); setErr("");
|
||||
try {
|
||||
const r = await sendJson<IntelImportResult>("/api/assessments/x/intel", "POST", { text, sourceUrl: url });
|
||||
setResult(r);
|
||||
// Low-confidence guesses start unticked; the seller opts in to those.
|
||||
setPicked(new Set(r.proposals.filter((p) => p.confidence !== "low").map((p) => p.key)));
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (k: string) =>
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(k) ? next.delete(k) : next.add(k);
|
||||
return next;
|
||||
});
|
||||
|
||||
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="Import account intelligence"
|
||||
onClick={(e) => { if (e.target === e.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">Import account intelligence</h2>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
<div className="space-y-4 p-5">
|
||||
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
|
||||
Open the account dossier, select all (<kbd className="rounded bg-[var(--surface-2)] px-1">⌘A</kbd>) and copy
|
||||
(<kbd className="rounded bg-[var(--surface-2)] px-1">⌘C</kbd>), then paste below. The dossier stays internal —
|
||||
only the rows you accept become part of the assessment, and none of its commentary reaches the customer report.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="intel-url">Dossier link</label>
|
||||
<input id="intel-url" className="field" placeholder="https://docs.google.com/document/d/…"
|
||||
value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<p className="mt-1 text-[11.5px] text-[var(--text-muted)]">
|
||||
Stored on the assessment so it always points back at its source.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="intel-text">Dossier text</label>
|
||||
<textarea id="intel-text" className="field font-mono text-[12px]" rows={12}
|
||||
placeholder="Paste the full dossier here…" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<p className="mt-1 tabnum text-[11.5px] text-[var(--text-muted)]">
|
||||
{text.length ? `${num(text.length)} characters` : "Nothing pasted yet"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button className="btn" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={analyse} disabled={!text.trim() || busy}>
|
||||
{busy ? "Analysing…" : "Analyse dossier"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5">
|
||||
<Summary result={result} />
|
||||
|
||||
{result.warnings.length > 0 && (
|
||||
<ul className="mb-4 space-y-1 rounded-lg border px-3 py-2.5" style={{ borderColor: "var(--warning)" }}>
|
||||
{result.warnings.map((w) => (
|
||||
<li key={w} className="text-[12.5px]" style={{ color: "var(--text-secondary)" }}>⚠ {w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{result.proposals.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
Incumbent tools found — {picked.size} of {result.proposals.length} selected
|
||||
</h3>
|
||||
<button className="btn btn-ghost btn-sm ml-auto"
|
||||
onClick={() => setPicked(new Set(picked.size === result.proposals.length ? [] : result.proposals.map((p) => p.key)))}>
|
||||
{picked.size === result.proposals.length ? "Clear all" : "Select all"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul className="scroll-thin mb-4 max-h-[300px] space-y-1.5 overflow-y-auto">
|
||||
{result.proposals.map((p) => {
|
||||
const c = CONFIDENCE_STYLE[p.confidence];
|
||||
return (
|
||||
<li key={p.key}>
|
||||
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 hover:bg-[var(--surface-2)]">
|
||||
<input type="checkbox" className="mt-0.5" checked={picked.has(p.key)} onChange={() => toggle(p.key)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="text-[13px] font-medium">{p.label}</span>
|
||||
<span className="chip" style={{ borderColor: c.color, color: c.color }}>{c.label}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] text-[var(--text-secondary)]">
|
||||
→ {p.productIds.length ? p.productIds.map(prodName).join(", ") : "No enabled replacement — map it manually"}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11.5px] text-[var(--text-muted)]">
|
||||
Dossier says — {p.evidence}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result.shelfware.length > 0 && (
|
||||
<div className="mb-4 rounded-lg bg-[var(--surface-2)] p-3">
|
||||
<h3 className="mb-1.5 text-[13px] font-semibold">
|
||||
Entitled but not deployed — {result.shelfware.length} product{result.shelfware.length === 1 ? "" : "s"}
|
||||
</h3>
|
||||
<p className="mb-2 text-[12px] text-[var(--text-secondary)]">
|
||||
Already paid for. These are adoption plays, not displacements, so they are kept separately from the candidates.
|
||||
</p>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{result.shelfware.map((s) => (
|
||||
<li key={s.product} className="chip">{s.product} · {num(s.entitled)} entitled</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t pt-4">
|
||||
<button className="btn" onClick={() => setResult(null)}>Back</button>
|
||||
<button className="btn btn-primary"
|
||||
onClick={() => onAccept(result, result.proposals.filter((p) => picked.has(p.key)))}>
|
||||
Add {picked.size} tool{picked.size === 1 ? "" : "s"} & save intelligence
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ result }: { result: IntelImportResult }) {
|
||||
const i = result.intel;
|
||||
const facts = [
|
||||
["Company", i.company], ["Industry", i.industry], ["Classification", i.classification],
|
||||
["Renewal", i.renewalTimeframe], ["Renewal risk", i.renewalRisk], ["Sentiment", i.sentiment],
|
||||
["Deployment rate", i.deploymentRate], ["Applications", i.applications.length ? String(i.applications.length) : ""],
|
||||
].filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border p-3">
|
||||
<h3 className="mb-2 text-[13px] font-semibold">{i.sourceTitle || "Dossier"}</h3>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-4">
|
||||
{facts.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{k}</dt>
|
||||
<dd className="truncate text-[12.5px]" title={v}>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/LoadError.tsx
Normal file
16
src/components/LoadError.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="card p-10 text-center">
|
||||
<p className="text-[14px] font-semibold" style={{ color: "var(--critical)" }}>Could not load this page</p>
|
||||
<p className="mx-auto mt-2 max-w-[54ch] text-[13px] text-[var(--text-secondary)]">{message}</p>
|
||||
<div className="mt-5 flex justify-center gap-2">
|
||||
<button className="btn btn-primary" onClick={onRetry}>Retry</button>
|
||||
<Link className="btn" href="/">Back to assessments</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/Logo.tsx
Normal file
26
src/components/Logo.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
* and dark, and matches the favicon (src/app/icon.svg).
|
||||
*/
|
||||
export default function Logo({ size = 24 }: { size?: number }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<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" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill={`url(#${id})`} />
|
||||
<rect x="7" y="19" width="4" height="6" rx="1" fill="#fff" fillOpacity=".55" />
|
||||
<rect x="14" y="15" width="4" height="10" rx="1" fill="#fff" fillOpacity=".75" />
|
||||
<rect x="21" y="11" width="4" height="14" rx="1" fill="#fff" />
|
||||
<path d="M6 15.5 12.5 10.5 17.5 12.5 25.5 5.5" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M21 5.5h4.5V10" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
56
src/components/Nav.tsx
Normal file
56
src/components/Nav.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Logo from "./Logo";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Assessments" },
|
||||
{ href: "/knowledge-base", label: "Knowledge base" },
|
||||
{ href: "/catalog", label: "Product catalog" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const path = usePathname();
|
||||
const isReport = path.endsWith("/report");
|
||||
|
||||
// Questionnaire respondents get a standalone page with no route into the tool.
|
||||
if (path.startsWith("/q/")) return null;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`no-print sticky top-0 z-40 mb-6 border-b bg-[var(--plane)]/85 backdrop-blur-xl ${isReport ? "print:hidden" : ""}`}
|
||||
>
|
||||
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-2 px-4 sm:px-6">
|
||||
<Link href="/" className="mr-3 flex items-center gap-2.5">
|
||||
<Logo size={24} />
|
||||
<span className="text-[14px] font-semibold tracking-tight">Converge</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1">
|
||||
{LINKS.map((l) => {
|
||||
const active = l.href === "/" ? path === "/" || path.startsWith("/assessments") : path.startsWith(l.href);
|
||||
return (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className={`rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors ${
|
||||
active
|
||||
? "bg-[var(--surface-2)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
97
src/components/ProductPicker.tsx
Normal file
97
src/components/ProductPicker.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import type { Catalog } from "@/lib/types";
|
||||
|
||||
/** Ranks catalog products against free-text about the incumbent tool. */
|
||||
export function suggest(catalog: Catalog, incumbent: string, domain: string, limit = 6) {
|
||||
const needle = incumbent.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
const words = needle.split(/\s+/).filter((w) => w.length > 2);
|
||||
|
||||
return catalog.products
|
||||
.filter((p) => p.enabled)
|
||||
.map((p) => {
|
||||
let score = 0;
|
||||
for (const r of p.replaces) {
|
||||
const rl = r.toLowerCase();
|
||||
if (rl === needle) score += 100;
|
||||
else if (rl.includes(needle) || needle.includes(rl)) score += 60;
|
||||
else if (words.some((w) => rl.includes(w))) score += 22;
|
||||
}
|
||||
if (domain && p.domain === domain) score += 12;
|
||||
return { p, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.p.name.localeCompare(b.p.name))
|
||||
.slice(0, limit)
|
||||
.map((x) => x.p);
|
||||
}
|
||||
|
||||
export default function ProductPicker({
|
||||
catalog, value, onChange,
|
||||
}: {
|
||||
catalog: Catalog; value: string[]; onChange: (ids: string[]) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const byId = useMemo(() => new Map(catalog.products.map((p) => [p.id, p])), [catalog]);
|
||||
const buById = useMemo(() => new Map(catalog.businessUnits.map((b) => [b.id, b])), [catalog]);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return catalog.products
|
||||
.filter((p) => p.enabled && !value.includes(p.id))
|
||||
.filter((p) => !needle || p.name.toLowerCase().includes(needle) || p.replaces.some((r) => r.toLowerCase().includes(needle)))
|
||||
.slice(0, 40);
|
||||
}, [catalog, q, value]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={boxRef}
|
||||
onBlur={(e) => { if (!boxRef.current?.contains(e.relatedTarget as Node)) setOpen(false); }}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((id) => {
|
||||
const p = byId.get(id);
|
||||
const b = p ? buById.get(p.bu) : undefined;
|
||||
return (
|
||||
<span key={id} className="chip" style={{ borderColor: b?.color }}>
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: b?.color ?? "var(--text-muted)" }} />
|
||||
{p?.name ?? id}
|
||||
<button className="ml-0.5 text-[var(--text-muted)] hover:text-[var(--critical)]"
|
||||
aria-label={`Remove ${p?.name ?? id}`} onClick={() => onChange(value.filter((v) => v !== id))}>×</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button className="btn btn-sm" onClick={() => setOpen((v) => !v)}>+ Map product</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-2 w-[min(440px,92vw)] rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-2 shadow-[var(--shadow)]">
|
||||
<input autoFocus className="field mb-2" placeholder="Search our portfolio…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<div className="scroll-thin max-h-[300px] overflow-y-auto">
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2 py-4 text-center text-[12.5px] text-[var(--text-muted)]">
|
||||
No enabled products match. Check the catalog filters.
|
||||
</p>
|
||||
) : matches.map((p) => {
|
||||
const b = buById.get(p.bu);
|
||||
return (
|
||||
<button key={p.id}
|
||||
className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-[var(--surface-2)]"
|
||||
onClick={() => { onChange([...value, p.id]); setQ(""); }}>
|
||||
<span className="mt-[5px] inline-block h-2 w-2 shrink-0 rounded-full" style={{ background: b?.color }} />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] font-medium">{p.name}</span>
|
||||
<span className="block truncate text-[11.5px] text-[var(--text-muted)]">{b?.short} · {p.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
src/components/SurveyPanel.tsx
Normal file
168
src/components/SurveyPanel.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Catalog, Survey } from "@/lib/types";
|
||||
import { errorMessage, sendJson } from "@/lib/client";
|
||||
import { answeredCount, pendingTools, surveyTools, surveyableDomains } from "@/lib/survey";
|
||||
import { dateLabel } from "@/lib/format";
|
||||
|
||||
/** Create, share and track landscape questionnaires for one assessment. */
|
||||
export default function SurveyPanel({
|
||||
assessmentId, catalog, surveys, onChanged, onReview, onClose,
|
||||
}: {
|
||||
assessmentId: string;
|
||||
catalog: Catalog;
|
||||
surveys: Survey[];
|
||||
onChanged: () => void;
|
||||
onReview: (s: Survey) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const domains = useMemo(() => surveyableDomains(catalog), [catalog]);
|
||||
const [creating, setCreating] = useState(surveys.length === 0);
|
||||
const [name, setName] = useState("");
|
||||
const [picked, setPicked] = useState<Set<string>>(() => new Set(domains.map((d) => d.id)));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [copied, setCopied] = useState("");
|
||||
|
||||
const base = `/api/assessments/${assessmentId}/surveys`;
|
||||
const origin = typeof window === "undefined" ? "" : window.location.origin;
|
||||
const isLocal = /\/\/(localhost|127\.0\.0\.1|\[::1\])(:|$)/.test(origin);
|
||||
const linkFor = (s: Survey) => `${origin}/q/${s.id}`;
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
setBusy(true); setErr("");
|
||||
try { await fn(); onChanged(); } catch (e) { setErr(errorMessage(e)); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function create() {
|
||||
run(async () => {
|
||||
const s = await sendJson<Survey>(base, "POST", { respondentName: name, domains: [...picked] });
|
||||
setCreating(false); setName("");
|
||||
copy(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function copy(s: Survey) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(linkFor(s));
|
||||
setCopied(s.id);
|
||||
setTimeout(() => setCopied((c) => (c === s.id ? "" : c)), 2000);
|
||||
} catch {
|
||||
// Clipboard can be blocked; the link is visible to copy by hand.
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = (id: string) => setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
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="Landscape questionnaires"
|
||||
onClick={(e) => { if (e.target === e.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">Landscape questionnaires</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] leading-relaxed text-[var(--text-secondary)]">
|
||||
Send a colleague a link. They step through one category at a time, pick the tools in place, and add
|
||||
spend and contract dates if they know them. Submitted answers wait in a review inbox here. Nothing reaches the
|
||||
assessment until you accept it.
|
||||
</p>
|
||||
|
||||
{isLocal && (
|
||||
<p className="rounded-lg border px-3 py-2 text-[12.5px]" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>
|
||||
⚠ You’re on <code>{origin}</code>, so links will point at <em>localhost</em> and only work on this machine.
|
||||
Open the app using this computer’s network name or IP address before copying links for colleagues.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{surveys.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{surveys.map((s) => {
|
||||
const total = s.domains.length;
|
||||
const done = answeredCount(s);
|
||||
const pending = pendingTools(s).length;
|
||||
const tools = surveyTools(s).length;
|
||||
return (
|
||||
<li key={s.id} className="rounded-xl border p-3.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[13.5px] font-semibold">{s.respondentName || "Unnamed respondent"}</span>
|
||||
{s.status === "submitted" ? (
|
||||
pending > 0
|
||||
? <span className="chip" style={{ borderColor: "var(--accent)", color: "var(--accent)" }}>Submitted · {pending} to review</span>
|
||||
: <span className="chip" style={{ borderColor: "var(--success-text)", color: "var(--success-text)" }}>Reviewed</span>
|
||||
) : (
|
||||
<span className="chip">{done ? `In progress · ${done}/${total}` : "Not started"}</span>
|
||||
)}
|
||||
<span className="text-[12px] text-[var(--text-muted)]">
|
||||
{total} categories · {tools} tool{tools === 1 ? "" : "s"} named
|
||||
{s.submittedAt ? ` · submitted ${dateLabel(s.submittedAt)}` : ` · created ${dateLabel(s.createdAt)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<input className="field max-w-[420px] py-1 font-mono text-[11.5px]" readOnly value={linkFor(s)}
|
||||
aria-label="Questionnaire link" onFocus={(e) => e.currentTarget.select()} />
|
||||
<button className="btn btn-sm" onClick={() => copy(s)}>{copied === s.id ? "Copied ✓" : "Copy link"}</button>
|
||||
<a className="btn btn-sm" href={`/q/${s.id}`} target="_blank" rel="noreferrer">Open ↗</a>
|
||||
<span className="ml-auto flex gap-2">
|
||||
{pending > 0 && <button className="btn btn-primary btn-sm" onClick={() => onReview(s)}>Review</button>}
|
||||
{s.status === "submitted" && (
|
||||
<button className="btn btn-sm" disabled={busy} title="Let the respondent edit and submit again"
|
||||
onClick={() => run(() => sendJson(`${base}/${s.id}`, "PUT", { action: "reopen" }))}>Reopen</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm btn-danger" disabled={busy}
|
||||
onClick={() => { if (confirm("Delete this questionnaire? Its link stops working. Tools you already accepted stay in the assessment.")) run(() => sendJson(`${base}/${s.id}`, "DELETE")); }}>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{creating ? (
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="mb-3 text-[13.5px] font-semibold">New questionnaire</h3>
|
||||
<div className="mb-4 max-w-[340px]">
|
||||
<label className="label" htmlFor="sv-name">Who is it for?</label>
|
||||
<input id="sv-name" className="field" placeholder="e.g. Dana Lee, CIO" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="label mb-0">Categories to ask about · {picked.size} of {domains.length}</span>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={() => setPicked(new Set(domains.map((d) => d.id)))}>All</button>
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setPicked(new Set())}>None</button>
|
||||
</div>
|
||||
<div className="scroll-thin grid max-h-[300px] gap-1 overflow-y-auto sm:grid-cols-2">
|
||||
{domains.map((d) => (
|
||||
<label key={d.id} className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 hover:bg-[var(--surface-2)]">
|
||||
<input type="checkbox" className="mt-0.5" checked={picked.has(d.id)} onChange={() => toggle(d.id)} />
|
||||
<span className="text-[12.5px]">{d.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
{surveys.length > 0 && <button className="btn" onClick={() => setCreating(false)}>Cancel</button>}
|
||||
<button className="btn btn-primary" onClick={create} disabled={busy || picked.size === 0}>
|
||||
{busy ? "Creating…" : "Create & copy link"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn" onClick={() => setCreating(true)}>+ New questionnaire</button>
|
||||
)}
|
||||
|
||||
{err && <p className="text-[13px]" style={{ color: "var(--critical)" }}>{err}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/components/SurveyReview.tsx
Normal file
147
src/components/SurveyReview.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Candidate, Catalog, KbEntry, KnowledgeBase, Survey } from "@/lib/types";
|
||||
import { type PendingTool, candidateKey, pendingTools } from "@/lib/survey";
|
||||
import { candidateLabel, splitVendor } from "@/lib/kb";
|
||||
import { dateLabel, money } from "@/lib/format";
|
||||
|
||||
/** The knowledge-base entry a respondent's answer refers to, by id or by name. */
|
||||
export function kbEntryFor(kb: KnowledgeBase | null, t: Pick<PendingTool, "kbEntryId" | "label">): KbEntry | null {
|
||||
if (!kb) return null;
|
||||
const byId = t.kbEntryId ? kb.entries.find((e) => e.id === t.kbEntryId) : undefined;
|
||||
if (byId) return byId;
|
||||
const l = t.label.toLowerCase();
|
||||
return kb.entries.find((e) => e.label.toLowerCase() === l || e.aliases.some((a) => a.toLowerCase() === l)) ?? null;
|
||||
}
|
||||
|
||||
export function toolVendorProduct(kb: KnowledgeBase | null, t: PendingTool) {
|
||||
const e = kbEntryFor(kb, t);
|
||||
return e ? { vendor: e.vendor, product: e.product } : splitVendor(t.label);
|
||||
}
|
||||
|
||||
/** Review inbox for one submitted questionnaire. */
|
||||
export default function SurveyReview({
|
||||
survey, catalog, kb, candidates, onAccept, onClose,
|
||||
}: {
|
||||
survey: Survey;
|
||||
catalog: Catalog;
|
||||
kb: KnowledgeBase | null;
|
||||
candidates: Candidate[];
|
||||
onAccept: (accepted: PendingTool[], dismissed: PendingTool[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tools = useMemo(() => pendingTools(survey), [survey]);
|
||||
const [picked, setPicked] = useState<Set<string>>(() => new Set(tools.map((t) => t.key)));
|
||||
const existing = useMemo(() => new Set(candidates.map((c) => candidateLabel(c).toLowerCase())), [candidates]);
|
||||
|
||||
const domainName = (d: string) => catalog.domains.find((x) => x.id === d)?.name ?? d;
|
||||
const prodName = (id: string) => catalog.products.find((p) => p.id === id)?.name ?? id;
|
||||
const byDomain = survey.domains
|
||||
.map((d) => ({ d, items: tools.filter((t) => t.domain === d) }))
|
||||
.filter((g) => g.items.length);
|
||||
const noneIn = survey.domains.filter((d) => survey.answers[d]?.status === "none");
|
||||
const unsure = survey.domains.filter((d) => survey.answers[d]?.status === "unsure");
|
||||
|
||||
const toggle = (k: string) => setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(k) ? next.delete(k) : next.add(k);
|
||||
return next;
|
||||
});
|
||||
|
||||
const accepted = tools.filter((t) => picked.has(t.key));
|
||||
|
||||
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="Review questionnaire"
|
||||
onClick={(e) => { if (e.target === e.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">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Review questionnaire</h2>
|
||||
<p className="text-[12px] text-[var(--text-muted)]">
|
||||
{survey.respondentName || "Unnamed respondent"} · submitted {dateLabel(survey.submittedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm ml-auto" onClick={onClose} aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-semibold">Tools named — {picked.size} of {tools.length} selected</h3>
|
||||
<button className="btn btn-ghost btn-sm ml-auto"
|
||||
onClick={() => setPicked(new Set(picked.size === tools.length ? [] : tools.map((t) => t.key)))}>
|
||||
{picked.size === tools.length ? "Clear all" : "Select all"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="scroll-thin mb-4 max-h-[420px] space-y-3 overflow-y-auto">
|
||||
{byDomain.map(({ d, items }) => (
|
||||
<div key={d}>
|
||||
<div className="mb-1 text-[10.5px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{domainName(d)}</div>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((t) => {
|
||||
const entry = kbEntryFor(kb, t);
|
||||
const vp = toolVendorProduct(kb, t);
|
||||
const dup = existing.has(candidateKey(vp.vendor, vp.product));
|
||||
const facts = [
|
||||
t.annualSpend !== null ? `${money(t.annualSpend)}/yr` : "",
|
||||
t.contractEnd ? `contract ends ${t.contractEnd}` : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<li key={t.key}>
|
||||
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border px-3 py-2.5 hover:bg-[var(--surface-2)]">
|
||||
<input type="checkbox" className="mt-0.5" checked={picked.has(t.key)} onChange={() => toggle(t.key)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-baseline gap-2">
|
||||
<span className="text-[13px] font-medium">{t.label}</span>
|
||||
{!entry && <span className="chip">Typed in</span>}
|
||||
{dup && <span className="chip" style={{ borderColor: "var(--warning)", color: "var(--text-secondary)" }}>Already in assessment</span>}
|
||||
{facts && <span className="tabnum text-[12px] text-[var(--text-secondary)]">{facts}</span>}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] text-[var(--text-secondary)]">
|
||||
{dup
|
||||
? "Fills in spend and contract date on the existing line where they are blank."
|
||||
: `→ ${entry?.productIds.length ? entry.productIds.map(prodName).join(", ") : "No replacement mapped yet — set it on the line"}`}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{tools.length === 0 && <p className="text-[13px] text-[var(--text-muted)]">No tools left to review.</p>}
|
||||
</div>
|
||||
|
||||
{(noneIn.length > 0 || unsure.length > 0) && (
|
||||
<div className="mb-4 space-y-2 rounded-lg bg-[var(--surface-2)] p-3 text-[12.5px]">
|
||||
{noneIn.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Nothing in place:</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">{noneIn.map(domainName).join(", ")}. These are whitespace, not displacements.</span>
|
||||
</div>
|
||||
)}
|
||||
{unsure.length > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold">Not sure:</span>{" "}
|
||||
<span className="text-[var(--text-secondary)]">{unsure.map(domainName).join(", ")}. Worth a follow-up question.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-4">
|
||||
<span className="mr-auto text-[12px] text-[var(--text-muted)]">Unticked tools are dismissed and won’t be offered again.</span>
|
||||
<button className="btn" onClick={onClose}>Later</button>
|
||||
<button className="btn btn-primary" disabled={tools.length === 0}
|
||||
onClick={() => onAccept(accepted, tools.filter((t) => !picked.has(t.key)))}>
|
||||
Add {accepted.length} tool{accepted.length === 1 ? "" : "s"} to assessment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/ThemeToggle.tsx
Normal file
39
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Mode = "light" | "dark" | "system";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [mode, setMode] = useState<Mode>("system");
|
||||
|
||||
useEffect(() => {
|
||||
setMode(((localStorage.getItem("prt-theme") as Mode) || "system"));
|
||||
}, []);
|
||||
|
||||
function apply(next: Mode) {
|
||||
setMode(next);
|
||||
if (next === "system") {
|
||||
localStorage.removeItem("prt-theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("prt-theme", next);
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
}
|
||||
}
|
||||
|
||||
const order: Mode[] = ["system", "light", "dark"];
|
||||
const icon = { system: "◐", light: "☀", dark: "☾" }[mode];
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
title={`Theme: ${mode}`}
|
||||
aria-label={`Theme: ${mode}. Click to change.`}
|
||||
onClick={() => apply(order[(order.indexOf(mode) + 1) % order.length])}
|
||||
>
|
||||
<span className="text-[14px] leading-none">{icon}</span>
|
||||
<span className="hidden sm:inline capitalize">{mode}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
370
src/components/charts.tsx
Normal file
370
src/components/charts.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { money } from "@/lib/format";
|
||||
|
||||
export const SERIES = [
|
||||
"var(--series-1)", "var(--series-2)", "var(--series-3)", "var(--series-4)",
|
||||
"var(--series-5)", "var(--series-6)", "var(--series-7)", "var(--series-8)",
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------- frame */
|
||||
|
||||
export function ChartFrame({
|
||||
title, subtitle, children, table, right,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
/** Every chart ships a table view: it is the relief for low-contrast hues. */
|
||||
table?: { headers: string[]; rows: (string | number)[][] };
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
return (
|
||||
<section className="card avoid-break p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[14px] font-semibold tracking-tight">{title}</h3>
|
||||
{subtitle && <p className="mt-0.5 text-[12.5px] text-[var(--text-muted)]">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{right}
|
||||
{table && (
|
||||
<button className="btn btn-ghost btn-sm no-print" onClick={() => setShowTable((v) => !v)}>
|
||||
{showTable ? "Chart" : "Table"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showTable && table ? <DataTable {...table} /> : children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable({ headers, rows }: { headers: string[]; rows: (string | number)[][] }) {
|
||||
return (
|
||||
<div className="scroll-thin -mx-1 overflow-x-auto px-1">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{headers.map((h, i) => (
|
||||
<th key={h} className={`pb-2 font-semibold text-[var(--text-secondary)] ${i ? "pl-4 text-right" : "text-left"}`}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-b border-[var(--grid)] last:border-0">
|
||||
{r.map((c, j) => (
|
||||
<td key={j} className={`py-1.5 tabnum ${j ? "pl-4 text-right text-[var(--text-secondary)]" : "font-medium"}`}>{c}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ stat tile */
|
||||
|
||||
export function StatTile({
|
||||
label, value, sub, tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
tone?: "neutral" | "good" | "accent";
|
||||
}) {
|
||||
const color =
|
||||
tone === "good" ? "var(--success-text)" : tone === "accent" ? "var(--accent)" : "var(--text-primary)";
|
||||
return (
|
||||
<div className="card avoid-break p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[.05em] text-[var(--text-muted)]">{label}</div>
|
||||
<div className="mt-1.5 text-[26px] font-semibold leading-none tracking-tight" style={{ color }}>{value}</div>
|
||||
{sub && <div className="mt-1.5 text-[12px] text-[var(--text-secondary)]">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- horizontal bar */
|
||||
|
||||
export type BarDatum = { label: string; value: number; color?: string; note?: string };
|
||||
|
||||
export function HBar({ data, formatter = (n: number) => money(n) }: { data: BarDatum[]; formatter?: (n: number) => string }) {
|
||||
const max = Math.max(1, ...data.map((d) => d.value));
|
||||
if (!data.length) return <Empty />;
|
||||
return (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{data.map((d) => (
|
||||
<li key={d.label} className="group">
|
||||
<div className="mb-1 flex items-baseline gap-3">
|
||||
<span className="truncate text-[12.5px] font-medium">{d.label}</span>
|
||||
<span className="ml-auto shrink-0 tabnum text-[12.5px] font-semibold">{formatter(d.value)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--grid)]">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${Math.max(1.5, (d.value / max) * 100)}%`, background: d.color ?? "var(--series-1)" }}
|
||||
title={`${d.label}: ${formatter(d.value)}${d.note ? ` — ${d.note}` : ""}`}
|
||||
/>
|
||||
</div>
|
||||
{d.note && <div className="mt-1 text-[11.5px] text-[var(--text-muted)]">{d.note}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- quadrant map */
|
||||
|
||||
export type QuadPoint = {
|
||||
id: string; x: number; y: number; size: number;
|
||||
label: string; sublabel: string; colorIndex: number; groupLabel: string;
|
||||
};
|
||||
|
||||
export function Quadrant({ points, groups }: { points: QuadPoint[]; groups: { label: string; colorIndex: number }[] }) {
|
||||
const [hover, setHover] = useState<QuadPoint | null>(null);
|
||||
const uid = useId();
|
||||
const W = 640, H = 420, PAD = { t: 18, r: 18, b: 44, l: 52 };
|
||||
const iw = W - PAD.l - PAD.r, ih = H - PAD.t - PAD.b;
|
||||
// Axes are fixed 1–5 rating scales, so the domain never moves with the data.
|
||||
const sx = (v: number) => PAD.l + ((v - 0.5) / 5) * iw;
|
||||
const sy = (v: number) => PAD.t + ih - ((v - 0.5) / 5) * ih;
|
||||
const maxSize = Math.max(1, ...points.map((p) => p.size));
|
||||
const r = (v: number) => 7 + 20 * Math.sqrt(Math.max(0, v) / maxSize);
|
||||
|
||||
// Scores are integers 1-5, so candidates routinely land on the same point.
|
||||
// Fan co-located bubbles out around their shared coordinate so none is hidden.
|
||||
const placed = (() => {
|
||||
const groups = new Map<string, QuadPoint[]>();
|
||||
for (const p of points) {
|
||||
const k = `${p.x}|${p.y}`;
|
||||
(groups.get(k) ?? groups.set(k, []).get(k)!).push(p);
|
||||
}
|
||||
const out: (QuadPoint & { ox: number; oy: number })[] = [];
|
||||
for (const group of groups.values()) {
|
||||
if (group.length === 1) {
|
||||
out.push({ ...group[0], ox: 0, oy: 0 });
|
||||
continue;
|
||||
}
|
||||
// Largest bubble sits in the centre; the rest ring it, biggest first.
|
||||
const sorted = [...group].sort((a, b) => b.size - a.size);
|
||||
const spread = Math.max(...sorted.map((p) => r(p.size))) * 0.95;
|
||||
sorted.forEach((p, i) => {
|
||||
const angle = (i / sorted.length) * Math.PI * 2 - Math.PI / 2;
|
||||
out.push({ ...p, ox: i === 0 ? 0 : Math.cos(angle) * spread, oy: i === 0 ? 0 : Math.sin(angle) * spread });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
if (!points.length) return <Empty />;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[680px]" role="img" aria-label="Capability parity versus switching ease">
|
||||
<rect x={sx(3)} y={PAD.t} width={PAD.l + iw - sx(3)} height={sy(3) - PAD.t} fill="var(--accent)" opacity=".05" />
|
||||
<text x={PAD.l + iw - 8} y={PAD.t + ih - 8} textAnchor="end" fontSize="10.5" fontWeight="600" fill="var(--text-muted)" letterSpacing=".05em">
|
||||
ACT NOW
|
||||
</text>
|
||||
<text x={PAD.l + 8} y={PAD.t + ih - 8} fontSize="10.5" fontWeight="600" fill="var(--text-muted)" letterSpacing=".05em">
|
||||
DEFER
|
||||
</text>
|
||||
|
||||
{[1, 2, 3, 4, 5].map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={sx(v)} y1={PAD.t} x2={sx(v)} y2={PAD.t + ih} stroke="var(--grid)" strokeWidth="1" />
|
||||
<line x1={PAD.l} y1={sy(v)} x2={PAD.l + iw} y2={sy(v)} stroke="var(--grid)" strokeWidth="1" />
|
||||
<text x={sx(v)} y={PAD.t + ih + 16} textAnchor="middle" fontSize="11" fill="var(--text-muted)" className="tabnum">{v}</text>
|
||||
<text x={PAD.l - 10} y={sy(v) + 4} textAnchor="end" fontSize="11" fill="var(--text-muted)" className="tabnum">{v}</text>
|
||||
</g>
|
||||
))}
|
||||
<line x1={PAD.l} y1={PAD.t + ih} x2={PAD.l + iw} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1.5" />
|
||||
<line x1={PAD.l} y1={PAD.t} x2={PAD.l} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1.5" />
|
||||
|
||||
<text x={PAD.l + iw / 2} y={H - 8} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--text-secondary)">
|
||||
Ease of switching →
|
||||
</text>
|
||||
<text x={14} y={PAD.t + ih / 2} textAnchor="middle" fontSize="11.5" fontWeight="600" fill="var(--text-secondary)"
|
||||
transform={`rotate(-90 14 ${PAD.t + ih / 2})`}>
|
||||
Capability parity →
|
||||
</text>
|
||||
|
||||
{[...placed].sort((a, b) => b.size - a.size).map((p) => {
|
||||
const on = hover?.id === p.id;
|
||||
const cx = sx(p.x) + p.ox, cy = sy(p.y) + p.oy;
|
||||
return (
|
||||
<g key={p.id} onMouseEnter={() => setHover(p)} onMouseLeave={() => setHover(null)} style={{ cursor: "pointer" }}>
|
||||
{/* 2px surface ring keeps overlapping bubbles legible */}
|
||||
<circle cx={cx} cy={cy} r={r(p.size) + 1.5} fill="none" stroke="var(--surface-1)" strokeWidth="2" opacity=".85" />
|
||||
<circle
|
||||
cx={cx} cy={cy} r={r(p.size)}
|
||||
fill={SERIES[p.colorIndex]} fillOpacity={on ? 0.55 : 0.34}
|
||||
stroke={SERIES[p.colorIndex]} strokeWidth={on ? 2.5 : 2}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/*
|
||||
The largest lines carry a visible label: hover is unavailable in print.
|
||||
Only the lead bubble of each cluster is labelled, or co-located points
|
||||
would stack their labels on top of one another.
|
||||
*/}
|
||||
{[...placed]
|
||||
.filter((p) => p.ox === 0 && p.oy === 0)
|
||||
.sort((a, b) => b.size - a.size)
|
||||
.slice(0, 4)
|
||||
.map((p) => (
|
||||
<text
|
||||
key={`lbl-${p.id}`}
|
||||
x={sx(p.x) + p.ox}
|
||||
y={sy(p.y) + p.oy - r(p.size) - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill="var(--text-secondary)"
|
||||
>
|
||||
{p.label.length > 22 ? `${p.label.slice(0, 21)}…` : p.label}
|
||||
</text>
|
||||
))}
|
||||
<desc id={uid}>Bubble area is annual spend at stake.</desc>
|
||||
</svg>
|
||||
|
||||
{hover && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-2 z-10 w-[min(300px,90%)] -translate-x-1/2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-1)] p-3 text-[12.5px] shadow-[var(--shadow)]">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: SERIES[hover.colorIndex] }} />
|
||||
<span className="truncate">{hover.label}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[var(--text-secondary)]">{hover.sublabel}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 tabnum text-[var(--text-muted)]">
|
||||
<span>Parity {hover.y}/5</span>
|
||||
<span>Switch ease {hover.x}/5</span>
|
||||
<span>{money(hover.size, true)} at stake</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Legend groups={groups} note="Bubble size = annual spend at stake" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Legend({ groups, note }: { groups: { label: string; colorIndex: number }[]; note?: string }) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-[var(--text-secondary)]">
|
||||
{groups.map((g) => (
|
||||
<span key={g.label} className="inline-flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: SERIES[g.colorIndex] }} />
|
||||
{g.label}
|
||||
</span>
|
||||
))}
|
||||
{note && <span className="ml-auto text-[var(--text-muted)]">{note}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- cumulative value */
|
||||
|
||||
export function PaybackCurve({
|
||||
annualSavings, migrationCost, years,
|
||||
}: { annualSavings: number; migrationCost: number; years: number }) {
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
const W = 640, H = 300, PAD = { t: 16, r: 16, b: 40, l: 72 };
|
||||
const iw = W - PAD.l - PAD.r, ih = H - PAD.t - PAD.b;
|
||||
|
||||
const steps = Math.max(1, years) * 4; // quarterly resolution
|
||||
const pts = Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const yr = (i / steps) * years;
|
||||
return { yr, net: annualSavings * yr - migrationCost, invested: migrationCost };
|
||||
});
|
||||
|
||||
const lo = Math.min(0, ...pts.map((p) => p.net));
|
||||
const hi = Math.max(1, ...pts.map((p) => p.net), migrationCost);
|
||||
const sx = (yr: number) => PAD.l + (yr / Math.max(years, 0.001)) * iw;
|
||||
const sy = (v: number) => PAD.t + ih - ((v - lo) / (hi - lo || 1)) * ih;
|
||||
|
||||
const path = pts.map((p, i) => `${i ? "L" : "M"}${sx(p.yr).toFixed(1)},${sy(p.net).toFixed(1)}`).join(" ");
|
||||
const area = `${path} L${sx(years)},${sy(0)} L${sx(0)},${sy(0)} Z`;
|
||||
const breakYr = annualSavings > 0 ? migrationCost / annualSavings : null;
|
||||
const h = hoverIdx === null ? null : pts[hoverIdx];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="mx-auto block w-full max-w-[680px]" role="img" aria-label="Cumulative net benefit over the planning horizon">
|
||||
<defs>
|
||||
<linearGradient id="pc-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--series-1)" stopOpacity=".28" />
|
||||
<stop offset="100%" stopColor="var(--series-1)" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{Array.from({ length: years + 1 }, (_, i) => (
|
||||
<g key={i}>
|
||||
<line x1={sx(i)} y1={PAD.t} x2={sx(i)} y2={PAD.t + ih} stroke="var(--grid)" strokeWidth="1" />
|
||||
<text x={sx(i)} y={PAD.t + ih + 16} textAnchor="middle" fontSize="11" fill="var(--text-muted)">
|
||||
{i === 0 ? "Start" : `Yr ${i}`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{[lo, (lo + hi) / 2, hi].map((v, i) => (
|
||||
<g key={i}>
|
||||
<line x1={PAD.l} y1={sy(v)} x2={PAD.l + iw} y2={sy(v)} stroke="var(--grid)" strokeWidth="1" />
|
||||
<text x={PAD.l - 10} y={sy(v) + 4} textAnchor="end" fontSize="11" fill="var(--text-muted)" className="tabnum">{money(v, true)}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
<line x1={PAD.l} y1={sy(0)} x2={PAD.l + iw} y2={sy(0)} stroke="var(--axis)" strokeWidth="1.5" />
|
||||
<path d={area} fill="url(#pc-fill)" />
|
||||
<path d={path} fill="none" stroke="var(--series-1)" strokeWidth="2" strokeLinecap="round" />
|
||||
|
||||
{breakYr !== null && breakYr <= years && (
|
||||
<g>
|
||||
<line x1={sx(breakYr)} y1={PAD.t} x2={sx(breakYr)} y2={PAD.t + ih} stroke="var(--success-text)" strokeWidth="1.5" strokeDasharray="4 3" />
|
||||
<circle cx={sx(breakYr)} cy={sy(0)} r="4.5" fill="var(--success-text)" stroke="var(--surface-1)" strokeWidth="2" />
|
||||
<text x={sx(breakYr) + 7} y={PAD.t + 14} fontSize="11" fontWeight="600" fill="var(--success-text)">
|
||||
Breakeven {breakYr < 1 ? `${Math.round(breakYr * 12)} mo` : `${breakYr.toFixed(1)} yr`}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
<text x={sx(years) - 4} y={sy(pts[pts.length - 1].net) - 10} textAnchor="end" fontSize="12" fontWeight="600" fill="var(--text-primary)" className="tabnum">
|
||||
{money(pts[pts.length - 1].net, true)}
|
||||
</text>
|
||||
|
||||
{h && (
|
||||
<g>
|
||||
<line x1={sx(h.yr)} y1={PAD.t} x2={sx(h.yr)} y2={PAD.t + ih} stroke="var(--axis)" strokeWidth="1" />
|
||||
<circle cx={sx(h.yr)} cy={sy(h.net)} r="4.5" fill="var(--series-1)" stroke="var(--surface-1)" strokeWidth="2" />
|
||||
</g>
|
||||
)}
|
||||
<rect
|
||||
x={PAD.l} y={PAD.t} width={iw} height={ih} fill="transparent"
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
onMouseMove={(e) => {
|
||||
const box = (e.target as SVGRectElement).getBoundingClientRect();
|
||||
const frac = (e.clientX - box.left) / box.width;
|
||||
setHoverIdx(Math.round(Math.min(1, Math.max(0, frac)) * steps));
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{h && (
|
||||
<div className="pointer-events-none absolute right-4 top-3 rounded-lg border border-[var(--border-strong)] bg-[var(--surface-1)] px-3 py-2 text-[12.5px] shadow-[var(--shadow)]">
|
||||
<div className="font-semibold tabnum">{h.yr < 1 ? `Month ${Math.round(h.yr * 12)}` : `Year ${h.yr.toFixed(2)}`}</div>
|
||||
<div className="tabnum text-[var(--text-secondary)]">Net benefit {money(h.net)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ children = "Nothing to show yet." }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-[120px] items-center justify-center rounded-xl border border-dashed text-[13px] text-[var(--text-muted)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/lib/client.ts
Normal file
64
src/lib/client.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Fetch helpers for the client pages.
|
||||
*
|
||||
* Every page load depends on these, so they must fail loudly rather than
|
||||
* resolve to undefined: a silent rejection leaves a page stuck on "Loading…".
|
||||
* An empty body is treated as an error, which is what a dev-server route that
|
||||
* is still compiling returns on its first hit.
|
||||
*/
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function parse<T>(res: Response, url: string): Promise<T> {
|
||||
const body = await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try { detail = (JSON.parse(body) as { error?: string }).error ?? ""; } catch { /* not JSON */ }
|
||||
throw new HttpError(detail || `${res.status} ${res.statusText} from ${url}`, res.status);
|
||||
}
|
||||
if (!body.trim()) throw new HttpError(`Empty response from ${url}`, res.status);
|
||||
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new HttpError(`Malformed JSON from ${url}`, res.status);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET with one retry, since a cold dev-server route can drop the first hit. */
|
||||
export async function getJson<T>(url: string, retries = 1): Promise<T> {
|
||||
try {
|
||||
return await parse<T>(await fetch(url, { cache: "no-store" }), url);
|
||||
} catch (err) {
|
||||
// A 404 is a real answer, not a transient failure — never retry it.
|
||||
if (retries > 0 && !(err instanceof HttpError && err.status === 404)) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
return getJson<T>(url, retries - 1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendJson<T>(url: string, method: "POST" | "PUT" | "DELETE", body?: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return parse<T>(res, url);
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown) {
|
||||
if (err instanceof HttpError) return err.message;
|
||||
if (err instanceof Error) return err.message;
|
||||
return "Something went wrong.";
|
||||
}
|
||||
322
src/lib/dossier.ts
Normal file
322
src/lib/dossier.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type {
|
||||
AccountIntel, Catalog, IntelApp, IntelConsumption, IntelFact,
|
||||
IntelImportResult, IntelProposal, KbEntry, KnowledgeBase,
|
||||
} from "./types";
|
||||
import { splitVendor } from "./kb";
|
||||
|
||||
/* ------------------------------------------------------------------ parsing */
|
||||
|
||||
const BULLET = /^[\s•·▪◦*\-–—]+/;
|
||||
|
||||
const clean = (s: string) => s.replace(BULLET, "").replace(/\s+/g, " ").trim();
|
||||
|
||||
/** Headings the dossier uses. Matching is loose so wording drift does not break it. */
|
||||
const SECTIONS: { id: string; test: RegExp }[] = [
|
||||
{ id: "summary", test: /^executive summary$/i },
|
||||
{ id: "scores", test: /^composite scores$/i },
|
||||
{ id: "profile", test: /^public company profile$/i },
|
||||
{ id: "vcf", test: /^(vcf|vcf (status|plan|footprint).*)$/i },
|
||||
{ id: "infra", test: /^infrastructure( &| and)? cloud strategy$/i },
|
||||
{ id: "consumption", test: /^product consumption$/i },
|
||||
{ id: "products", test: /^products$/i },
|
||||
{ id: "apps", test: /^applications?$/i },
|
||||
{ id: "workstreams", test: /^workstreams$/i },
|
||||
{ id: "narrative", test: /^(tech debt signals|cloud strategy assessment|cloud strategic partnerships|cloud repatriation signals|devops platform assessment|ai initiatives|security posture|modernization signals)$/i },
|
||||
];
|
||||
|
||||
/** `Name (4)` marks an application category. */
|
||||
const APP_CATEGORY = /^([A-Za-z][A-Za-z0-9 &/+._-]{1,40})\s*\((\d+)\)$/;
|
||||
/** `VSPHERE: Entitled 35768, Deployed 33400, Adoption High` */
|
||||
const CONSUMPTION = /^(.+?):\s*(?:Entitled\s+([\d,]+))?[,\s]*(?:Deployed\s+([\d,]+))?[,\s]*(?:Adoption\s+([A-Za-z ]+))?$/i;
|
||||
|
||||
/**
|
||||
* Infrastructure lines name one vendor per layer, so each carries the capability
|
||||
* domains it could plausibly displace. Without this gate a compute vendor like
|
||||
* "HPE" matches unrelated entries that merely start with the same word.
|
||||
*/
|
||||
const INFRA_KEYS: { key: string; domains: string[] }[] = [
|
||||
{ key: "Compute Vendor", domains: ["compute", "private-cloud"] },
|
||||
{ key: "Hypervisor", domains: ["compute", "private-cloud"] },
|
||||
{ key: "Storage Platform", domains: ["storage"] },
|
||||
{ key: "Storage Vendor", domains: ["storage"] },
|
||||
{ key: "Network Vendor", domains: ["networking", "adc"] },
|
||||
{ key: "Firewall Vendor", domains: ["network-sec"] },
|
||||
{ key: "Load Balancer Vendor", domains: ["adc"] },
|
||||
{ key: "Backup Vendor", domains: ["resiliency", "storage"] },
|
||||
{ key: "Database Vendor", domains: ["data"] },
|
||||
{ key: "Observability Vendor", domains: ["observability"] },
|
||||
{ key: "Container Platform", domains: ["app-platform"] },
|
||||
];
|
||||
|
||||
const CLOUD_KEYS = ["Cloud Strategy", "Primary Hyperscaler", "Workload Distribution"];
|
||||
|
||||
const num = (s: string | undefined) => (s ? Number(s.replace(/,/g, "")) : 0);
|
||||
|
||||
export function parseDossier(raw: string, sourceUrl = ""): AccountIntel {
|
||||
const lines = raw.replace(/\r\n?/g, "\n").split("\n");
|
||||
|
||||
const kv = new Map<string, string>(); // every "Key: value" seen
|
||||
const sectionKv = new Map<string, Map<string, string>>();
|
||||
const apps: IntelApp[] = [];
|
||||
const consumption: IntelConsumption[] = [];
|
||||
const narratives: { heading: string; body: string }[] = [];
|
||||
|
||||
let section = "";
|
||||
let appCategory = "";
|
||||
let narrativeHeading = "";
|
||||
let narrativeBody: string[] = [];
|
||||
|
||||
const flushNarrative = () => {
|
||||
if (narrativeHeading && narrativeBody.length) {
|
||||
narratives.push({ heading: narrativeHeading, body: narrativeBody.join(" ").trim() });
|
||||
}
|
||||
narrativeHeading = "";
|
||||
narrativeBody = [];
|
||||
};
|
||||
|
||||
const title = clean(lines.find((l) => clean(l).length > 0) ?? "");
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = clean(rawLine);
|
||||
if (!line) continue;
|
||||
|
||||
const heading = SECTIONS.find((s) => s.test.test(line));
|
||||
if (heading) {
|
||||
flushNarrative();
|
||||
section = heading.id;
|
||||
appCategory = "";
|
||||
if (heading.id === "narrative") narrativeHeading = line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A short, colon-free line inside the narrative run starts a new narrative block.
|
||||
if (section === "narrative" && !line.includes(":") && line.length < 60 && /^[A-Z]/.test(line)) {
|
||||
flushNarrative();
|
||||
narrativeHeading = line;
|
||||
continue;
|
||||
}
|
||||
if (section === "narrative") { narrativeBody.push(line); continue; }
|
||||
|
||||
if (section === "apps") {
|
||||
const cat = APP_CATEGORY.exec(line);
|
||||
if (cat) { appCategory = cat[1].trim(); continue; }
|
||||
if (appCategory && !line.includes(":")) { apps.push({ category: appCategory, name: line }); continue; }
|
||||
}
|
||||
|
||||
if (section === "products" || section === "consumption") {
|
||||
const m = CONSUMPTION.exec(line);
|
||||
if (m && /entitled|deployed|adoption/i.test(line)) {
|
||||
consumption.push({
|
||||
product: m[1].trim(),
|
||||
entitled: num(m[2]),
|
||||
deployed: num(m[3]),
|
||||
adoption: (m[4] ?? "").trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const idx = line.indexOf(":");
|
||||
if (idx > 0 && idx < 60) {
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (key && value) {
|
||||
if (!kv.has(key)) kv.set(key, value);
|
||||
if (!sectionKv.has(section)) sectionKv.set(section, new Map());
|
||||
const sm = sectionKv.get(section)!;
|
||||
if (!sm.has(key)) sm.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
flushNarrative();
|
||||
|
||||
const get = (...keys: string[]) => {
|
||||
for (const k of keys) {
|
||||
const v = kv.get(k);
|
||||
if (v && !/^not available$/i.test(v)) return v;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const pick = (keys: string[]): IntelFact[] =>
|
||||
keys.map((k) => ({ label: k, value: kv.get(k) ?? "" })).filter((f) => f.value && !/^not available$/i.test(f.value));
|
||||
|
||||
const vcfKeys = [...kv.keys()].filter((k) => /^(vcf|cores|deployment model|adoption stage)/i.test(k));
|
||||
|
||||
return {
|
||||
sourceUrl,
|
||||
sourceTitle: title,
|
||||
importedAt: new Date().toISOString(),
|
||||
company: get("Company"),
|
||||
industry: get("Industry"),
|
||||
classification: get("Classification"),
|
||||
accountStatus: get("Account Status"),
|
||||
sentiment: get("Account Sentiment", "Sentiment"),
|
||||
renewalTimeframe: get("Renewal Timeframe"),
|
||||
renewalRisk: get("Renewal Risk"),
|
||||
healthScore: get("Account Health Score"),
|
||||
intelligenceScore: get("Intelligence Score"),
|
||||
tamValueScore: get("TAM Value Score"),
|
||||
vcf: pick(vcfKeys),
|
||||
infrastructure: pick(INFRA_KEYS.map((k) => k.key)),
|
||||
cloud: pick(CLOUD_KEYS),
|
||||
applications: apps,
|
||||
consumption,
|
||||
deploymentRate: get("Overall Deployment Rate"),
|
||||
narratives,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- proposals */
|
||||
|
||||
/** Values that describe a situation rather than a product worth matching. */
|
||||
const NOT_A_PRODUCT = /^(not available|none|n\/a|unknown|tbd|various|multiple|no .*|standard|hybrid.*|other)$/i;
|
||||
|
||||
/** Splits a name into comparable lowercase tokens. */
|
||||
const tokens = (s: string) => s.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean);
|
||||
|
||||
/**
|
||||
* Scores one candidate name against the dossier value.
|
||||
*
|
||||
* Matching is token-aligned rather than raw substring: "HPE" must be a whole
|
||||
* token of the entry, so it cannot bleed into an unrelated longer name, and a
|
||||
* two-character vendor like "F5" still matches exactly.
|
||||
*/
|
||||
function scoreName(name: string, q: string): number {
|
||||
const n = name.toLowerCase().trim();
|
||||
if (n === q) return 100;
|
||||
|
||||
const nt = tokens(n);
|
||||
const qt = tokens(q);
|
||||
if (!nt.length || !qt.length) return 0;
|
||||
|
||||
// The query is the entry's leading words ("F5" vs "F5 BIG-IP LTM"), or vice versa.
|
||||
const leads = (a: string[], b: string[]) => b.length >= a.length && a.every((w, i) => w === b[i]);
|
||||
if (leads(qt, nt) || leads(nt, qt)) return 85;
|
||||
|
||||
const shared = qt.filter((w) => nt.includes(w));
|
||||
if (!shared.length) return 0;
|
||||
// Every query token present somewhere in the entry name.
|
||||
if (shared.length === qt.length) return 70;
|
||||
// A single distinctive shared token is weak evidence on its own.
|
||||
return shared.some((w) => w.length >= 4) ? 55 : 0;
|
||||
}
|
||||
|
||||
function matchKb(
|
||||
kb: KnowledgeBase,
|
||||
needle: string,
|
||||
enabledIds: Set<string>,
|
||||
opts: { domains?: string[]; minScore?: number } = {},
|
||||
): { entry: KbEntry; confidence: IntelProposal["confidence"] } | null {
|
||||
const q = needle.trim().toLowerCase();
|
||||
if (!q || q.length < 2 || NOT_A_PRODUCT.test(q)) return null;
|
||||
|
||||
const minScore = opts.minScore ?? 70;
|
||||
let best: { entry: KbEntry; score: number } | null = null;
|
||||
|
||||
for (const e of kb.entries) {
|
||||
// Situations and status-quo entries are never proposed automatically.
|
||||
if (e.competitive === false) continue;
|
||||
// A named infrastructure layer can only be displaced by that layer's tools.
|
||||
if (opts.domains?.length && e.domain && !opts.domains.includes(e.domain)) continue;
|
||||
|
||||
const names = [e.label, [e.vendor, e.product].filter(Boolean).join(" "), ...e.aliases].filter(Boolean);
|
||||
let score = Math.max(0, ...names.map((n) => scoreName(n, q)));
|
||||
|
||||
// The vendor name alone only counts when that is the whole query — an
|
||||
// infrastructure line like "Cisco". Otherwise every Oracle entry would
|
||||
// match every Oracle product.
|
||||
// An infrastructure line naming only the vendor is strong evidence the
|
||||
// vendor is present; which of its products is the uncertain part.
|
||||
if (e.vendor && q === e.vendor.toLowerCase()) score = Math.max(score, 92);
|
||||
if (!score) continue;
|
||||
|
||||
const enabled = e.productIds.filter((id) => enabledIds.has(id)).length;
|
||||
score += Math.min(8, e.observations.length * 3);
|
||||
// An entry whose only mappings are disabled products cannot be actioned,
|
||||
// so a sibling entry with a live mapping should win.
|
||||
if (enabled) score += 4;
|
||||
else if (e.productIds.length) score -= 15;
|
||||
|
||||
if (!best || score > best.score) best = { entry: e, score };
|
||||
}
|
||||
|
||||
if (!best || best.score < minScore) return null;
|
||||
return {
|
||||
entry: best.entry,
|
||||
confidence: best.score >= 95 ? "high" : best.score >= 80 ? "medium" : "low",
|
||||
};
|
||||
}
|
||||
|
||||
function toProposal(key: string, evidence: string, m: { entry: KbEntry; confidence: IntelProposal["confidence"] }): IntelProposal {
|
||||
const e = m.entry;
|
||||
const { vendor, product } = e.vendor || e.product ? { vendor: e.vendor, product: e.product } : splitVendor(e.label);
|
||||
return {
|
||||
key, evidence,
|
||||
label: e.label, vendor, product,
|
||||
domain: e.domain,
|
||||
productIds: [...e.productIds],
|
||||
kbEntryId: e.id,
|
||||
confidence: m.confidence,
|
||||
capability: e.capability,
|
||||
switchEase: e.switchEase,
|
||||
timeToRoiMonths: e.timeToRoiMonths,
|
||||
savingsPct: e.savingsPct,
|
||||
migrationCost: e.migrationCost,
|
||||
risks: e.risks,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildImportResult(
|
||||
raw: string, sourceUrl: string, kb: KnowledgeBase, catalog: Catalog,
|
||||
): IntelImportResult {
|
||||
const intel = parseDossier(raw, sourceUrl);
|
||||
const warnings: string[] = [];
|
||||
const proposals: IntelProposal[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const enabledIds = new Set(catalog.products.filter((p) => p.enabled).map((p) => p.id));
|
||||
|
||||
const consider = (evidence: string, needle: string, opts?: { domains?: string[]; minScore?: number }) => {
|
||||
const m = matchKb(kb, needle, enabledIds, opts);
|
||||
if (!m) return;
|
||||
if (seen.has(m.entry.id)) return;
|
||||
seen.add(m.entry.id);
|
||||
proposals.push(toProposal(m.entry.id, evidence, m));
|
||||
};
|
||||
|
||||
// Infrastructure vendors are the highest-signal source: one named vendor per layer.
|
||||
for (const f of intel.infrastructure) {
|
||||
const domains = INFRA_KEYS.find((k) => k.key === f.label)?.domains;
|
||||
const evidence = `${f.label}: ${f.value}`;
|
||||
// "Everpure (Pure)" — try the bare name and the parenthetical.
|
||||
const inner = /\(([^)]+)\)/.exec(f.value)?.[1];
|
||||
consider(evidence, f.value.replace(/\s*\([^)]*\)/, ""), { domains });
|
||||
if (inner) consider(evidence, inner, { domains });
|
||||
}
|
||||
|
||||
// Application names are noisy, so they must clear a higher bar than a vendor line.
|
||||
for (const app of intel.applications) {
|
||||
const evidence = `${app.category}: ${app.name}`;
|
||||
consider(evidence, app.name.replace(/\s*\([^)]*\)/, ""), { minScore: 84 });
|
||||
for (const part of app.name.split(/\s*\/\s*/)) {
|
||||
consider(evidence, part.replace(/\s*\([^)]*\)/, ""), { minScore: 84 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!intel.company) warnings.push("No “Company:” line found — check the whole dossier was pasted.");
|
||||
if (!intel.infrastructure.length) warnings.push("No infrastructure vendors found; only application matches were proposed.");
|
||||
if (!proposals.length) warnings.push("Nothing matched the knowledge base. Add the tools manually and they will be learned.");
|
||||
|
||||
// Never propose a product that is switched off in the catalog.
|
||||
for (const p of proposals) p.productIds = p.productIds.filter((id) => enabledIds.has(id));
|
||||
|
||||
const shelfware = intel.consumption.filter((c) => c.entitled > 0 && c.deployed === 0);
|
||||
|
||||
proposals.sort((a, b) => {
|
||||
const rank = { high: 0, medium: 1, low: 2 };
|
||||
return rank[a.confidence] - rank[b.confidence] || a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return { intel, proposals, shelfware, warnings };
|
||||
}
|
||||
42
src/lib/format.ts
Normal file
42
src/lib/format.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export function money(n: number, compact = false) {
|
||||
if (!isFinite(n)) return "—";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
notation: compact ? "compact" : "standard",
|
||||
maximumFractionDigits: compact ? 1 : 0,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
export function num(n: number) {
|
||||
return new Intl.NumberFormat("en-US").format(Math.round(n));
|
||||
}
|
||||
|
||||
export function monthsLabel(m: number | null) {
|
||||
if (m === null) return "n/a";
|
||||
if (m <= 0) return "Immediate";
|
||||
if (m < 12) return `${m.toFixed(m < 3 ? 1 : 0)} months`;
|
||||
return `${(m / 12).toFixed(1)} years`;
|
||||
}
|
||||
|
||||
export function dateLabel(iso: string) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return isNaN(+d) ? iso : d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export const SCORE_LABEL: Record<number, string> = { 1: "Very low", 2: "Low", 3: "Moderate", 4: "High", 5: "Very high" };
|
||||
export const CAPABILITY_LABEL: Record<number, string> = {
|
||||
1: "Major gaps",
|
||||
2: "Partial overlap",
|
||||
3: "Most capabilities",
|
||||
4: "Full parity",
|
||||
5: "Parity plus upside",
|
||||
};
|
||||
export const SWITCH_LABEL: Record<number, string> = {
|
||||
1: "Very hard — deep integration",
|
||||
2: "Hard — significant rework",
|
||||
3: "Moderate — planned project",
|
||||
4: "Easy — mostly config",
|
||||
5: "Trivial — drop-in",
|
||||
};
|
||||
243
src/lib/kb.ts
Normal file
243
src/lib/kb.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { Assessment, Candidate, KbEntry, KbObservation, KnowledgeBase } from "./types";
|
||||
|
||||
/**
|
||||
* Catalog `replaces` entries that name a status quo or an internal option
|
||||
* rather than a competitor we could displace.
|
||||
*/
|
||||
const NON_COMPETITIVE = [
|
||||
/\(status quo\)/i, /\(internal alternative\)/i, /status quo/i,
|
||||
/\bDIY\b/i, /\bscripts?\b/i, /spreadsheets/i, /\bmanual\b/i,
|
||||
/^custom /i, /^native /i, /^internal /i, /^self-managed/i, /^unsupported/i,
|
||||
/^physical /i, /^consulting/i, /^fragmented/i, /^serialized/i, /^shared /i,
|
||||
/^point[- ]tool/i, /^point IAM/i, /^no\b/i, /^none\b/i, /^password-only/i,
|
||||
/^display statements/i, /^unencrypted/i, /^green[- ]screen/i, /^cron\b/i,
|
||||
/rewrite|redevelopment/i, /^migration to /i, /^rehost/i, /^low-code platforms/i,
|
||||
/^multiple /i, /^various /i, /^hand-written/i, /^ISPF /i, /^unsupported community/i,
|
||||
];
|
||||
|
||||
export function isCompetitive(label: string) {
|
||||
return !NON_COMPETITIVE.some((re) => re.test(label));
|
||||
}
|
||||
|
||||
export const KB_DEFAULTS = {
|
||||
capability: 4,
|
||||
switchEase: 3,
|
||||
timeToRoiMonths: 12,
|
||||
savingsPct: 50,
|
||||
migrationCost: 0,
|
||||
};
|
||||
|
||||
/** Vendors whose names run to more than one word, so the split does not cut them short. */
|
||||
const MULTIWORD_VENDORS = [
|
||||
"Red Hat", "Palo Alto", "Check Point", "Micro Focus", "Trend Micro", "Arctic Wolf", "Red Canary",
|
||||
"Ping Identity", "One Identity", "Salt Security", "Noname Security", "Traceable AI", "Aqua Security",
|
||||
"Airlock Digital", "Abnormal Security", "Menlo Security", "Recorded Future", "Scale Computing",
|
||||
"Wind River", "Software AG", "Cato Networks", "Zero Networks", "Digital Guardian", "Snow Software",
|
||||
"Cardinal Commerce", "Duo Security", "Crunchy Data", "Morpheus Data", "Sumo Logic", "Prisma Cloud",
|
||||
"Amazon Web Services", "Google Cloud", "Scale Computing", "Data Theorem", "Wiz Runtime",
|
||||
];
|
||||
|
||||
export function splitVendor(label: string): { vendor: string; product: string } {
|
||||
const clean = label.replace(/\s+/g, " ").trim();
|
||||
for (const v of MULTIWORD_VENDORS) {
|
||||
if (clean.toLowerCase().startsWith(v.toLowerCase() + " ")) {
|
||||
return { vendor: v, product: clean.slice(v.length).trim() };
|
||||
}
|
||||
if (clean.toLowerCase() === v.toLowerCase()) return { vendor: v, product: "" };
|
||||
}
|
||||
const parts = clean.split(" ");
|
||||
if (parts.length === 1) return { vendor: clean, product: "" };
|
||||
return { vendor: parts[0], product: parts.slice(1).join(" ") };
|
||||
}
|
||||
|
||||
export function kbId(label: string) {
|
||||
return (
|
||||
label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 72) || "entry"
|
||||
);
|
||||
}
|
||||
|
||||
/** The key an assessment candidate matches against. */
|
||||
export function candidateLabel(c: Pick<Candidate, "incumbentVendor" | "incumbentProduct">) {
|
||||
return [c.incumbentVendor, c.incumbentProduct].map((s) => s.trim()).filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const median = (xs: number[]) => {
|
||||
if (!xs.length) return 0;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
};
|
||||
|
||||
/**
|
||||
* Recomputes an entry's defaults from its observations.
|
||||
*
|
||||
* Median rather than latest, so one unusual deal does not distort the entry;
|
||||
* product mapping takes the ids that show up in at least half of the uses.
|
||||
*/
|
||||
export function recomputeDefaults(entry: KbEntry): KbEntry {
|
||||
if (entry.pinned) return entry;
|
||||
|
||||
// With no observations left, the entry is seeded again — so it must show the
|
||||
// seed baseline rather than keep numbers learned from a deal that is gone.
|
||||
// The product mapping is kept: that came from the catalog, not from a deal.
|
||||
if (entry.observations.length === 0) return { ...entry, ...KB_DEFAULTS };
|
||||
|
||||
const obs = entry.observations;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const o of obs) for (const id of new Set(o.productIds)) counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
const majority = [...counts.entries()]
|
||||
.filter(([, n]) => n * 2 >= obs.length)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([id]) => id);
|
||||
|
||||
return {
|
||||
...entry,
|
||||
productIds: majority.length ? majority : entry.productIds,
|
||||
capability: Math.round(median(obs.map((o) => o.capability))),
|
||||
switchEase: Math.round(median(obs.map((o) => o.switchEase))),
|
||||
timeToRoiMonths: Math.round(median(obs.map((o) => o.timeToRoiMonths))),
|
||||
savingsPct: Math.round(median(obs.map((o) => o.savingsPct))),
|
||||
migrationCost: Math.round(median(obs.map((o) => o.migrationCost))),
|
||||
};
|
||||
}
|
||||
|
||||
export function newEntry(label: string, origin: KbEntry["origin"]): KbEntry {
|
||||
const { vendor, product } = splitVendor(label);
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: kbId(label),
|
||||
label,
|
||||
vendor,
|
||||
product,
|
||||
aliases: [],
|
||||
domain: "",
|
||||
productIds: [],
|
||||
...KB_DEFAULTS,
|
||||
unitLabel: "",
|
||||
risks: "",
|
||||
notes: "",
|
||||
competitive: isCompetitive(label),
|
||||
pinned: false,
|
||||
origin,
|
||||
observations: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds an assessment's candidates into the knowledge base.
|
||||
*
|
||||
* Observations are keyed by assessment + candidate, so re-saving an assessment
|
||||
* updates that candidate's observation in place instead of stacking duplicates.
|
||||
* Returns null when nothing changed, so an unchanged save writes no file.
|
||||
*/
|
||||
export function learnFromAssessment(kb: KnowledgeBase, a: Assessment): KnowledgeBase | null {
|
||||
const before = JSON.stringify(kb.entries);
|
||||
const byId = new Map(kb.entries.map((e) => [e.id, e]));
|
||||
const now = new Date().toISOString();
|
||||
|
||||
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);
|
||||
let entry = byId.get(id);
|
||||
if (!entry) {
|
||||
entry = newEntry(label, "learned");
|
||||
entry.domain = c.domain;
|
||||
byId.set(id, entry);
|
||||
}
|
||||
|
||||
const observation: KbObservation = {
|
||||
sourceKey: `${a.id}::${c.id}`,
|
||||
assessmentId: a.id,
|
||||
customerName: a.customerName,
|
||||
annualSpend: c.annualSpend,
|
||||
units: c.units,
|
||||
capability: c.capability,
|
||||
switchEase: c.switchEase,
|
||||
timeToRoiMonths: c.timeToRoiMonths,
|
||||
savingsPct: c.savingsPct,
|
||||
migrationCost: c.migrationCost,
|
||||
productIds: [...c.productIds],
|
||||
recordedAt: now,
|
||||
};
|
||||
|
||||
entry.observations = [
|
||||
...entry.observations.filter((o) => o.sourceKey !== observation.sourceKey),
|
||||
observation,
|
||||
];
|
||||
if (!entry.domain) entry.domain = c.domain;
|
||||
if (!entry.unitLabel && c.unitLabel) entry.unitLabel = c.unitLabel;
|
||||
if (!entry.risks && c.risks.trim()) entry.risks = c.risks.trim();
|
||||
|
||||
const next = recomputeDefaults(entry);
|
||||
next.updatedAt = now;
|
||||
byId.set(id, next);
|
||||
}
|
||||
|
||||
// Candidates that were removed or renamed must not leave stale observations
|
||||
// behind, or the medians keep reflecting rows the seller has since deleted.
|
||||
const liveKeys = new Set(
|
||||
a.candidates
|
||||
.filter((c) => candidateLabel(c) && c.productIds.length > 0)
|
||||
.map((c) => `${a.id}::${c.id}`),
|
||||
);
|
||||
for (const [id, entry] of byId) {
|
||||
const kept = entry.observations.filter((o) => o.assessmentId !== a.id || liveKeys.has(o.sourceKey));
|
||||
if (kept.length === entry.observations.length) continue;
|
||||
const next = recomputeDefaults({ ...entry, observations: kept });
|
||||
next.updatedAt = now;
|
||||
byId.set(id, next);
|
||||
}
|
||||
|
||||
const entries = [...byId.values()].sort((x, y) => x.label.localeCompare(y.label));
|
||||
if (JSON.stringify(entries) === before) return null;
|
||||
return { ...kb, entries, updatedAt: now };
|
||||
}
|
||||
|
||||
/** Drops every observation contributed by an assessment that no longer exists. */
|
||||
export function forgetAssessment(kb: KnowledgeBase, assessmentId: string): KnowledgeBase | null {
|
||||
const now = new Date().toISOString();
|
||||
let changed = false;
|
||||
|
||||
const entries = kb.entries.map((entry) => {
|
||||
const kept = entry.observations.filter((o) => o.assessmentId !== assessmentId);
|
||||
if (kept.length === entry.observations.length) return entry;
|
||||
changed = true;
|
||||
return { ...recomputeDefaults({ ...entry, observations: kept }), updatedAt: now };
|
||||
});
|
||||
|
||||
return changed ? { ...kb, entries, updatedAt: now } : null;
|
||||
}
|
||||
|
||||
/** Ranks knowledge-base entries against what the seller has typed so far. */
|
||||
export function searchKb(kb: KnowledgeBase, query: string, limit = 8): KbEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return [...kb.entries].sort((a, b) => b.observations.length - a.observations.length).slice(0, limit);
|
||||
}
|
||||
return kb.entries
|
||||
.map((e) => {
|
||||
const hay = [e.label, e.vendor, e.product, ...e.aliases].join(" ").toLowerCase();
|
||||
let score = 0;
|
||||
if (hay.startsWith(q)) score += 100;
|
||||
else if (e.label.toLowerCase().includes(q)) score += 60;
|
||||
else if (hay.includes(q)) score += 30;
|
||||
else return { e, score: 0 };
|
||||
// Entries proven in real deals outrank ones only seeded from the catalog.
|
||||
score += Math.min(20, e.observations.length * 4);
|
||||
return { e, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.e.label.localeCompare(b.e.label))
|
||||
.slice(0, limit)
|
||||
.map((x) => x.e);
|
||||
}
|
||||
17
src/lib/redact.ts
Normal file
17
src/lib/redact.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Assessment } from "./types";
|
||||
|
||||
/**
|
||||
* Strips everything internal from an assessment.
|
||||
*
|
||||
* The account dossier mixes customer-safe facts with pricing strategy,
|
||||
* competitive risk and personal details, and candidate `notes` are explicitly
|
||||
* the seller's private workings. Neither belongs in a customer deliverable, so
|
||||
* they are removed at the boundary rather than merely hidden in the view.
|
||||
*/
|
||||
export function customerSafe(a: Assessment): Assessment {
|
||||
return {
|
||||
...a,
|
||||
accountIntel: null,
|
||||
candidates: a.candidates.map((c) => ({ ...c, notes: "" })),
|
||||
};
|
||||
}
|
||||
196
src/lib/scoring.ts
Normal file
196
src/lib/scoring.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { Assessment, Candidate, Catalog, Weights } from "./types";
|
||||
|
||||
export const DEFAULT_WEIGHTS: Weights = {
|
||||
capability: 35,
|
||||
switchEase: 25,
|
||||
timeToRoi: 25,
|
||||
spend: 15,
|
||||
};
|
||||
|
||||
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. */
|
||||
export function roiNorm(months: number) {
|
||||
return clamp01(1 - (months - 6) / 30);
|
||||
}
|
||||
|
||||
export type CandidateMetrics = {
|
||||
annualSavings: number;
|
||||
horizonGross: number;
|
||||
horizonNet: number;
|
||||
paybackMonths: number | null;
|
||||
roiPct: number | null;
|
||||
score: number;
|
||||
wave: 1 | 2 | 3;
|
||||
};
|
||||
|
||||
export type Rollup = {
|
||||
metrics: Map<string, CandidateMetrics>;
|
||||
included: Candidate[];
|
||||
totalAnnualSpend: number;
|
||||
addressableSpend: number;
|
||||
totalAnnualSavings: number;
|
||||
totalMigrationCost: number;
|
||||
horizonGross: number;
|
||||
horizonNet: number;
|
||||
blendedPaybackMonths: number | null;
|
||||
savingsPct: number;
|
||||
avgScore: number;
|
||||
toolsRetired: number;
|
||||
vendorsRetired: number;
|
||||
/** Tools the customer runs after consolidation: our distinct products plus anything left unmapped. */
|
||||
toolsAfter: number;
|
||||
/** Vendors after consolidation: us (if anything is mapped) plus the vendors of unmapped tools. */
|
||||
vendorsAfter: number;
|
||||
unmappedCount: number;
|
||||
byWave: Record<1 | 2 | 3, Candidate[]>;
|
||||
byBu: BuValue[];
|
||||
byDomain: { domainId: string; name: string; savings: number; spend: number; count: 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 {
|
||||
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 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 };
|
||||
}
|
||||
|
||||
export function rollup(a: Assessment, catalog: Catalog): Rollup {
|
||||
const included = a.candidates.filter((c) => c.included);
|
||||
const maxSpend = Math.max(0, ...included.map((c) => c.annualSpend));
|
||||
const metrics = new Map<string, CandidateMetrics>();
|
||||
for (const c of a.candidates) {
|
||||
metrics.set(c.id, scoreCandidate(c, a.weights, maxSpend, a.horizonYears));
|
||||
}
|
||||
|
||||
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 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 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.
|
||||
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.count += 1;
|
||||
for (const id of c.productIds) if (prodById.get(id)?.bu === bu) e.products.add(id);
|
||||
buAgg.set(bu, e);
|
||||
}
|
||||
}
|
||||
const byBu: BuValue[] = catalog.businessUnits
|
||||
.filter((b) => buAgg.has(b.id))
|
||||
.map((b) => {
|
||||
const e = buAgg.get(b.id)!;
|
||||
return {
|
||||
buId: b.id, name: b.name, short: b.short, color: b.color,
|
||||
savings: e.savings, spend: e.spend, migrationCost: e.migrationCost,
|
||||
horizonNet: e.savings * a.horizonYears - e.migrationCost,
|
||||
count: e.count, productIds: [...e.products],
|
||||
};
|
||||
})
|
||||
.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 }>();
|
||||
for (const c of included) {
|
||||
const m = metrics.get(c.id)!;
|
||||
const e = domAgg.get(c.domain) ?? { savings: 0, spend: 0, count: 0 };
|
||||
e.savings += m.annualSavings;
|
||||
e.spend += c.annualSpend;
|
||||
e.count += 1;
|
||||
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)! }))
|
||||
.sort((x, y) => y.savings - x.savings);
|
||||
|
||||
return {
|
||||
metrics,
|
||||
included,
|
||||
totalAnnualSpend,
|
||||
addressableSpend,
|
||||
totalAnnualSavings,
|
||||
totalMigrationCost,
|
||||
horizonGross,
|
||||
horizonNet,
|
||||
blendedPaybackMonths,
|
||||
savingsPct: addressableSpend > 0 ? (totalAnnualSavings / addressableSpend) * 100 : 0,
|
||||
avgScore,
|
||||
toolsRetired: included.length,
|
||||
vendorsRetired: new Set(included.map((c) => c.incumbentVendor.trim().toLowerCase()).filter(Boolean)).size,
|
||||
toolsAfter,
|
||||
vendorsAfter,
|
||||
unmappedCount: unmapped.length,
|
||||
byWave,
|
||||
byDomain,
|
||||
byBu,
|
||||
};
|
||||
}
|
||||
|
||||
export const WAVE_LABEL: Record<1 | 2 | 3, string> = {
|
||||
1: "Wave 1 — Quick wins",
|
||||
2: "Wave 2 — Planned consolidation",
|
||||
3: "Wave 3 — Strategic / longer horizon",
|
||||
};
|
||||
|
||||
export const WAVE_BLURB: Record<1 | 2 | 3, string> = {
|
||||
1: "Strong functional parity, low switching friction and fast payback. Start here.",
|
||||
2: "Clear consolidation value with a manageable migration. Schedule against contract renewals.",
|
||||
3: "Material savings but higher effort, lower parity or a longer payback. Sequence last or revisit scope.",
|
||||
};
|
||||
238
src/lib/store.ts
Normal file
238
src/lib/store.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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 { DEFAULT_WEIGHTS, rollup } from "./scoring";
|
||||
import { forgetAssessment, learnFromAssessment } from "./kb";
|
||||
import { isSurveyToken } from "./survey";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
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");
|
||||
|
||||
async function readJson<T>(file: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(file, "utf8")) as T;
|
||||
}
|
||||
|
||||
async function writeJson(file: string, value: unknown) {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
// Write-then-rename so a crash mid-write can never truncate a saved assessment.
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
await fs.rename(tmp, file);
|
||||
}
|
||||
|
||||
export async function getCatalog(): Promise<Catalog> {
|
||||
return readJson<Catalog>(CATALOG_PATH);
|
||||
}
|
||||
|
||||
export async function saveCatalog(c: Catalog): Promise<Catalog> {
|
||||
c.updatedAt = new Date().toISOString().slice(0, 10);
|
||||
await writeJson(CATALOG_PATH, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
export async function getKnowledgeBase(): Promise<KnowledgeBase> {
|
||||
try {
|
||||
return await readJson<KnowledgeBase>(KB_PATH);
|
||||
} catch {
|
||||
return { version: 1, updatedAt: new Date().toISOString(), entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveKnowledgeBase(kb: KnowledgeBase): Promise<KnowledgeBase> {
|
||||
kb.updatedAt = new Date().toISOString();
|
||||
await writeJson(KB_PATH, kb);
|
||||
return kb;
|
||||
}
|
||||
|
||||
/** Assessment ids become filenames, so they must not escape the assessments dir. */
|
||||
export function isSafeId(id: string) {
|
||||
return /^[a-z0-9][a-z0-9-]{0,63}$/.test(id);
|
||||
}
|
||||
|
||||
function assessmentPath(id: string) {
|
||||
if (!isSafeId(id)) throw new Error(`Invalid assessment id: ${id}`);
|
||||
return path.join(ASSESSMENT_DIR, `${id}.json`);
|
||||
}
|
||||
|
||||
export async function listAssessments(): Promise<AssessmentSummary[]> {
|
||||
await fs.mkdir(ASSESSMENT_DIR, { recursive: true });
|
||||
const files = (await fs.readdir(ASSESSMENT_DIR)).filter((f) => f.endsWith(".json"));
|
||||
const catalog = await getCatalog();
|
||||
const out: AssessmentSummary[] = [];
|
||||
for (const f of files) {
|
||||
try {
|
||||
const a = await readJson<Assessment>(path.join(ASSESSMENT_DIR, f));
|
||||
const r = rollup(a, catalog);
|
||||
out.push({
|
||||
id: a.id,
|
||||
customerName: a.customerName,
|
||||
preparedBy: a.preparedBy,
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
candidateCount: a.candidates.length,
|
||||
annualSpend: r.addressableSpend,
|
||||
annualSavings: r.totalAnnualSavings,
|
||||
});
|
||||
} catch {
|
||||
// Skip unreadable or hand-edited files rather than failing the whole list.
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
export async function getAssessment(id: string): Promise<Assessment | null> {
|
||||
try {
|
||||
return await readJson<Assessment>(assessmentPath(id));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAssessment(a: Assessment): Promise<Assessment> {
|
||||
a.updatedAt = new Date().toISOString();
|
||||
await writeJson(assessmentPath(a.id), a);
|
||||
|
||||
// Every save folds what the seller entered back into the knowledge base, so
|
||||
// the next assessment starts from real numbers. A failure here must never
|
||||
// lose the assessment itself, which is already on disk.
|
||||
try {
|
||||
const kb = await getKnowledgeBase();
|
||||
const next = learnFromAssessment(kb, a);
|
||||
if (next) await saveKnowledgeBase(next);
|
||||
} catch (err) {
|
||||
console.error("Knowledge-base learning failed for", a.id, err);
|
||||
}
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
try {
|
||||
const kb = await getKnowledgeBase();
|
||||
const next = forgetAssessment(kb, id);
|
||||
if (next) await saveKnowledgeBase(next);
|
||||
} catch (err) {
|
||||
console.error("Knowledge-base cleanup failed for", id, err);
|
||||
}
|
||||
}
|
||||
|
||||
export function slugify(s: string) {
|
||||
const base = s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return base || "assessment";
|
||||
}
|
||||
|
||||
export async function createAssessment(input: Partial<Assessment>): Promise<Assessment> {
|
||||
await fs.mkdir(ASSESSMENT_DIR, { recursive: true });
|
||||
const customerName = (input.customerName || "New Customer").trim();
|
||||
const base = slugify(customerName);
|
||||
let id = base;
|
||||
let n = 2;
|
||||
while (await getAssessment(id)) id = `${base}-${n++}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const a: Assessment = {
|
||||
id,
|
||||
version: 1,
|
||||
customerName,
|
||||
industry: input.industry ?? "",
|
||||
preparedBy: input.preparedBy ?? "",
|
||||
preparedFor: input.preparedFor ?? "",
|
||||
opportunityRef: input.opportunityRef ?? "",
|
||||
executiveSummary: input.executiveSummary ?? "",
|
||||
horizonYears: input.horizonYears ?? 3,
|
||||
weights: input.weights ?? { ...DEFAULT_WEIGHTS },
|
||||
candidates: input.candidates ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await saveAssessment(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ questionnaires */
|
||||
|
||||
function surveyPath(id: string) {
|
||||
if (!isSurveyToken(id)) throw new Error("Invalid questionnaire id");
|
||||
return path.join(SURVEY_DIR, `${id}.json`);
|
||||
}
|
||||
|
||||
export async function getSurvey(id: string): Promise<Survey | null> {
|
||||
if (!isSurveyToken(id)) return null;
|
||||
try {
|
||||
return await readJson<Survey>(surveyPath(id));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSurveys(assessmentId: string): Promise<Survey[]> {
|
||||
await fs.mkdir(SURVEY_DIR, { recursive: true });
|
||||
const out: Survey[] = [];
|
||||
for (const f of await fs.readdir(SURVEY_DIR)) {
|
||||
if (!f.endsWith(".json")) continue;
|
||||
try {
|
||||
const s = await readJson<Survey>(path.join(SURVEY_DIR, f));
|
||||
if (s.assessmentId === assessmentId) out.push(s);
|
||||
} catch {
|
||||
// A half-written or hand-edited file should not hide the others.
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
export async function createSurvey(assessmentId: string, respondentName: string, domains: string[]): Promise<Survey> {
|
||||
const now = new Date().toISOString();
|
||||
const s: Survey = {
|
||||
id: crypto.randomBytes(16).toString("hex"),
|
||||
assessmentId,
|
||||
respondentName,
|
||||
domains,
|
||||
answers: {},
|
||||
status: "open",
|
||||
review: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
submittedAt: "",
|
||||
};
|
||||
await writeJson(surveyPath(s.id), s);
|
||||
return s;
|
||||
}
|
||||
|
||||
// The respondent and the seller both write the same file (answers vs. review
|
||||
// marks), so every change is a read-modify-write serialised per questionnaire.
|
||||
const surveyLocks = new Map<string, Promise<unknown>>();
|
||||
|
||||
export async function mutateSurvey(id: string, fn: (s: Survey) => void | string): Promise<Survey | string | null> {
|
||||
const prev = surveyLocks.get(id) ?? Promise.resolve();
|
||||
const run = prev.catch(() => {}).then(async () => {
|
||||
const s = await getSurvey(id);
|
||||
if (!s) return null;
|
||||
const refusal = fn(s);
|
||||
if (typeof refusal === "string") return refusal;
|
||||
s.updatedAt = new Date().toISOString();
|
||||
await writeJson(surveyPath(id), s);
|
||||
return s;
|
||||
});
|
||||
surveyLocks.set(id, run);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (surveyLocks.get(id) === run) surveyLocks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSurvey(id: string) {
|
||||
await fs.rm(surveyPath(id), { force: true });
|
||||
}
|
||||
104
src/lib/survey.ts
Normal file
104
src/lib/survey.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { Candidate, Catalog, KnowledgeBase, Survey, SurveyAnswer, SurveyTool } from "./types";
|
||||
|
||||
/** Share tokens: long enough that a link cannot be guessed, and safe as a filename. */
|
||||
export function isSurveyToken(id: string) {
|
||||
return /^[a-f0-9]{32}$/.test(id);
|
||||
}
|
||||
|
||||
/** Only categories we actually sell into are worth asking about. */
|
||||
export function surveyableDomains(catalog: Catalog) {
|
||||
const live = new Set(catalog.products.filter((p) => p.enabled).map((p) => p.domain));
|
||||
return catalog.domains.filter((d) => live.has(d.id));
|
||||
}
|
||||
|
||||
/** Most-used competitors first, so the likely answers are the visible chips. */
|
||||
export function domainOptions(kb: KnowledgeBase, domain: string) {
|
||||
return kb.entries
|
||||
.filter((e) => e.domain === domain && e.competitive)
|
||||
.sort((a, b) => b.observations.length - a.observations.length || a.label.localeCompare(b.label))
|
||||
.map((e) => ({ id: e.id, label: e.label }));
|
||||
}
|
||||
|
||||
export function answeredCount(s: Pick<Survey, "domains" | "answers">) {
|
||||
return s.domains.filter((d) => s.answers[d]?.status).length;
|
||||
}
|
||||
|
||||
export type PendingTool = SurveyTool & { domain: string };
|
||||
|
||||
export function surveyTools(s: Survey): PendingTool[] {
|
||||
return s.domains.flatMap((d) => {
|
||||
const a = s.answers[d];
|
||||
return a?.status === "tools" ? a.tools.map((t) => ({ ...t, domain: d })) : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Tools the seller has not accepted or dismissed yet. Only submitted surveys feed the inbox. */
|
||||
export function pendingTools(s: Survey): PendingTool[] {
|
||||
return s.status === "submitted" ? surveyTools(s).filter((t) => !s.review[t.key]) : [];
|
||||
}
|
||||
|
||||
/** "2027-03" → "2027-03-31": a contract that ends in a month runs to its last day. */
|
||||
export function monthEnd(ym: string) {
|
||||
const m = /^(\d{4})-(\d{2})$/.exec(ym);
|
||||
if (!m) return "";
|
||||
const last = new Date(Date.UTC(+m[1], +m[2], 0)).getUTCDate();
|
||||
return `${ym}-${String(last).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Accepts what an executive would type: "$1.2m", "250k", "90,000". */
|
||||
export function parseSpend(input: string): number | null {
|
||||
const s = input.trim().toLowerCase().replace(/[$,\s]/g, "");
|
||||
if (!s) return null;
|
||||
const m = /^(\d+(?:\.\d+)?)([km]|mm|b)?$/.exec(s);
|
||||
if (!m) return null;
|
||||
const mult = { k: 1e3, m: 1e6, mm: 1e6, b: 1e9 }[m[2] as "k" | "m" | "mm" | "b"] ?? 1;
|
||||
return Math.round(parseFloat(m[1]) * mult);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- sanitising */
|
||||
|
||||
const str = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
|
||||
|
||||
/**
|
||||
* The respondent endpoint is reachable by anyone holding the link, so the
|
||||
* answers it accepts are rebuilt field by field rather than trusted.
|
||||
*/
|
||||
export function sanitizeAnswers(domains: string[], raw: unknown): Record<string, SurveyAnswer> {
|
||||
const out: Record<string, SurveyAnswer> = {};
|
||||
const src = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||
for (const d of domains) {
|
||||
const a = src[d] as Partial<SurveyAnswer> | undefined;
|
||||
if (!a || typeof a !== "object") continue;
|
||||
const status = (["", "tools", "none", "unsure"] as const).find((x) => x === a.status) ?? "";
|
||||
const seen = new Set<string>();
|
||||
const tools: SurveyTool[] = [];
|
||||
for (const t of Array.isArray(a.tools) ? a.tools.slice(0, 25) : []) {
|
||||
const key = str(t?.key, 40);
|
||||
const label = str(t?.label, 120);
|
||||
if (!key || !label || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const spend = typeof t.annualSpend === "number" && isFinite(t.annualSpend) && t.annualSpend >= 0
|
||||
? Math.min(Math.round(t.annualSpend), 1e11) : null;
|
||||
const end = str(t.contractEnd, 7);
|
||||
tools.push({
|
||||
key, label,
|
||||
kbEntryId: str(t.kbEntryId, 80),
|
||||
annualSpend: spend,
|
||||
contractEnd: /^\d{4}-(0[1-9]|1[0-2])$/.test(end) ? end : "",
|
||||
});
|
||||
}
|
||||
out[d] = { status, tools: status === "tools" ? tools : [] };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- review → candidates */
|
||||
|
||||
export function candidateKey(vendor: string, product: string) {
|
||||
return `${vendor} ${product}`.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Fields a questionnaire answer contributes to a new or existing candidate. */
|
||||
export function toolFacts(t: SurveyTool): Pick<Candidate, "annualSpend" | "renewalDate"> {
|
||||
return { annualSpend: t.annualSpend ?? 0, renewalDate: monthEnd(t.contractEnd) };
|
||||
}
|
||||
170
src/lib/timeline.ts
Normal file
170
src/lib/timeline.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Assessment, Catalog } from "./types";
|
||||
import type { Rollup } from "./scoring";
|
||||
|
||||
/**
|
||||
* Implementation plan: turns the scored candidates into a dated project
|
||||
* sequence. Positions are in whole months from the plan start (month 0 is the
|
||||
* first full month after today), and a project occupies [start, end).
|
||||
*
|
||||
* Rules, in order:
|
||||
* 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.
|
||||
* 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 —
|
||||
* the customer keeps paying for a contract until it expires.
|
||||
*/
|
||||
|
||||
/** Migration length in months, by ease-of-switching score (5 = trivial). */
|
||||
export const DURATION_BY_EASE: Record<number, number> = { 5: 2, 4: 3, 3: 5, 2: 8, 1: 12 };
|
||||
/** Default migrations each workstream runs at once. */
|
||||
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 };
|
||||
|
||||
export type PlanItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
products: string;
|
||||
color: string;
|
||||
bu: string;
|
||||
wave: 1 | 2 | 3;
|
||||
start: number;
|
||||
end: number;
|
||||
/** Month the contract runs out (the bar position is the end of that month), null if unknown. */
|
||||
contractEnd: number | null;
|
||||
savingsFrom: number;
|
||||
annualSavings: number;
|
||||
migrationCost: number;
|
||||
anchor: "contract" | "sequenced";
|
||||
/** Contract ends before the migration can finish. */
|
||||
bridge: boolean;
|
||||
};
|
||||
|
||||
export type Plan = {
|
||||
/** ISO date of month 0 (first of the month). */
|
||||
startIso: string;
|
||||
items: PlanItem[];
|
||||
/** Month the last project completes. */
|
||||
completion: number;
|
||||
/** Month full run-rate savings are reached. */
|
||||
fullRunRate: number;
|
||||
/** Annual run-rate 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. */
|
||||
realizedInHorizon: number;
|
||||
/** The same horizon if every saving started on day one — the flat model the headline uses. */
|
||||
flatInHorizon: number;
|
||||
bridgeCount: number;
|
||||
unmappedCount: number;
|
||||
};
|
||||
|
||||
const monthIndex = (start: Date, iso: string) => {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
if (isNaN(+d)) return null;
|
||||
return (d.getUTCFullYear() - start.getUTCFullYear()) * 12 + (d.getUTCMonth() - start.getUTCMonth());
|
||||
};
|
||||
|
||||
export function monthDate(startIso: string, n: number) {
|
||||
const d = new Date(`${startIso}T00:00:00Z`);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
|
||||
}
|
||||
|
||||
export function monthLabel(startIso: string, n: number, long = false) {
|
||||
return monthDate(startIso, n).toLocaleDateString("en-US", { month: long ? "long" : "short", year: "numeric", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
export function quarterLabel(startIso: string, n: number) {
|
||||
const d = monthDate(startIso, n);
|
||||
return `Q${Math.floor(d.getUTCMonth() / 3) + 1} ${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
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 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 parallel = Math.max(1, Math.round(a.planParallel ?? DEFAULT_PARALLEL));
|
||||
const load = new Map<string, number[]>();
|
||||
const track = (ws: string) => { if (!load.has(ws)) load.set(ws, []); return load.get(ws)!; };
|
||||
const occupy = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) l[m] = (l[m] ?? 0) + 1; };
|
||||
const fits = (ws: string, s: number, e: number) => { const l = track(ws); for (let m = s; m < e; m++) if ((l[m] ?? 0) >= parallel) return false; return true; };
|
||||
|
||||
const items: (PlanItem & { duration: number; workstream: string })[] = [];
|
||||
const base = (c: (typeof mapped)[number]) => {
|
||||
const m = r.metrics.get(c.id)!;
|
||||
const bu = buById.get(prodById.get(c.productIds[0])?.bu ?? "");
|
||||
return {
|
||||
id: c.id,
|
||||
label: `${c.incumbentVendor} ${c.incumbentProduct}`.trim() || "Untitled",
|
||||
products: c.productIds.map((id) => prodById.get(id)?.name ?? id).join(", "),
|
||||
color: bu?.color ?? "var(--text-muted)",
|
||||
bu: bu?.short ?? "",
|
||||
workstream: bu?.id ?? "other",
|
||||
wave: m.wave,
|
||||
annualSavings: m.annualSavings,
|
||||
migrationCost: c.migrationCost,
|
||||
duration: DURATION_BY_EASE[Math.round(c.switchEase)] ?? 5,
|
||||
};
|
||||
};
|
||||
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
||||
// 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)!;
|
||||
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 });
|
||||
}
|
||||
|
||||
items.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));
|
||||
const horizon = a.horizonYears * 12;
|
||||
const span = Math.max(completion, fullRunRate, 1) + 2;
|
||||
const ramp = Array.from({ length: Math.max(span, horizon) }, (_, m) =>
|
||||
items.reduce((s, i) => s + (m >= i.savingsFrom ? i.annualSavings : 0), 0));
|
||||
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,
|
||||
realizedInHorizon, flatInHorizon,
|
||||
bridgeCount: clean.filter((i) => i.bridge).length,
|
||||
unmappedCount: r.included.length - mapped.length,
|
||||
};
|
||||
}
|
||||
266
src/lib/types.ts
Normal file
266
src/lib/types.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
export type BusinessUnit = { id: string; name: string; short: string; color: string };
|
||||
export type Domain = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Plain-language, vendor-neutral line shown to questionnaire respondents. */
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
bu: string;
|
||||
domain: string;
|
||||
description: string;
|
||||
replaces: string[];
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type Catalog = {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
source: string[];
|
||||
businessUnits: BusinessUnit[];
|
||||
domains: Domain[];
|
||||
products: Product[];
|
||||
};
|
||||
|
||||
/** How the three scored dimensions are combined into a rationalization score. */
|
||||
export type Weights = {
|
||||
capability: number; // comparability of our product vs. the incumbent
|
||||
switchEase: number; // how hard the migration is
|
||||
timeToRoi: number; // how fast the customer sees payback
|
||||
spend: number; // how much incumbent spend is at stake
|
||||
};
|
||||
|
||||
export type Candidate = {
|
||||
id: string;
|
||||
/** Incumbent / competitive tool being displaced. */
|
||||
incumbentVendor: string;
|
||||
incumbentProduct: string;
|
||||
domain: string;
|
||||
/** Catalog product ids proposed as the replacement. */
|
||||
productIds: string[];
|
||||
|
||||
annualSpend: number; // customer's current annual cost, USD
|
||||
units: number; // seats / cores / MSU — free-form quantity
|
||||
unitLabel: string;
|
||||
renewalDate: string; // ISO yyyy-mm-dd, "" if unknown
|
||||
|
||||
/** 1–5 scores. */
|
||||
capability: number; // 5 = full functional parity or better
|
||||
switchEase: number; // 5 = trivial to switch
|
||||
timeToRoiMonths: number; // months until cumulative benefit exceeds cost
|
||||
|
||||
/** Share of incumbent spend eliminated by consolidating, 0–100. */
|
||||
savingsPct: number;
|
||||
/** One-off migration cost, USD. */
|
||||
migrationCost: number;
|
||||
|
||||
strategicValue: number; // 1–5, non-financial importance to the customer
|
||||
risks: string;
|
||||
notes: string;
|
||||
/** Excluded from rollups and the customer report without being deleted. */
|
||||
included: boolean;
|
||||
};
|
||||
|
||||
export type Assessment = {
|
||||
id: string;
|
||||
version: number;
|
||||
customerName: string;
|
||||
industry: string;
|
||||
preparedBy: string;
|
||||
preparedFor: string;
|
||||
opportunityRef: string;
|
||||
executiveSummary: string;
|
||||
horizonYears: number;
|
||||
weights: Weights;
|
||||
candidates: Candidate[];
|
||||
/** Implementation plan: migrations each business-unit workstream runs in parallel (default 2). */
|
||||
planParallel?: number;
|
||||
/** Imported account dossier. Internal context; never rendered to the customer. */
|
||||
accountIntel?: AccountIntel | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AssessmentSummary = Pick<
|
||||
Assessment,
|
||||
"id" | "customerName" | "preparedBy" | "updatedAt" | "createdAt"
|
||||
> & { candidateCount: number; annualSpend: number; annualSavings: number };
|
||||
|
||||
/* ------------------------------------------------- competitive knowledge base */
|
||||
|
||||
/** One recorded use of a knowledge-base entry in a real assessment. */
|
||||
export type KbObservation = {
|
||||
/** `${assessmentId}::${candidateId}` — lets a re-save replace this observation in place. */
|
||||
sourceKey: string;
|
||||
assessmentId: string;
|
||||
customerName: string;
|
||||
annualSpend: number;
|
||||
units: number;
|
||||
capability: number;
|
||||
switchEase: number;
|
||||
timeToRoiMonths: number;
|
||||
savingsPct: number;
|
||||
migrationCost: number;
|
||||
productIds: string[];
|
||||
recordedAt: string;
|
||||
};
|
||||
|
||||
export type KbEntry = {
|
||||
id: string;
|
||||
/** Full competitor string as a seller would say it, e.g. "F5 BIG-IP LTM". */
|
||||
label: string;
|
||||
vendor: string;
|
||||
product: string;
|
||||
aliases: string[];
|
||||
domain: string;
|
||||
|
||||
/** Defaults applied when this entry is picked in an assessment. */
|
||||
productIds: string[];
|
||||
capability: number;
|
||||
switchEase: number;
|
||||
timeToRoiMonths: number;
|
||||
savingsPct: number;
|
||||
migrationCost: number;
|
||||
unitLabel: string;
|
||||
risks: string;
|
||||
notes: string;
|
||||
|
||||
/**
|
||||
* False for entries that describe a situation rather than a purchasable rival
|
||||
* ("Manual reviews", "Physical tape (status quo)"). They stay searchable by
|
||||
* hand but are never proposed automatically from a dossier.
|
||||
*/
|
||||
competitive: boolean;
|
||||
/** Pinned entries are curated: auto-learning never overwrites their defaults. */
|
||||
pinned: boolean;
|
||||
origin: "catalog" | "learned" | "manual";
|
||||
observations: KbObservation[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type KnowledgeBase = {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
entries: KbEntry[];
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------ account intelligence */
|
||||
|
||||
export type IntelFact = { label: string; value: string };
|
||||
export type IntelApp = { category: string; name: string };
|
||||
export type IntelConsumption = { product: string; entitled: number; deployed: number; adoption: string };
|
||||
|
||||
/**
|
||||
* Parsed account dossier. Internal by default: the source document mixes
|
||||
* customer-safe facts with internal pricing strategy and personal data, so
|
||||
* nothing here reaches the customer report unless a seller promotes it.
|
||||
*/
|
||||
export type AccountIntel = {
|
||||
sourceUrl: string;
|
||||
sourceTitle: string;
|
||||
importedAt: string;
|
||||
|
||||
company: string;
|
||||
industry: string;
|
||||
classification: string;
|
||||
accountStatus: string;
|
||||
sentiment: string;
|
||||
renewalTimeframe: string;
|
||||
renewalRisk: string;
|
||||
healthScore: string;
|
||||
intelligenceScore: string;
|
||||
tamValueScore: string;
|
||||
|
||||
vcf: IntelFact[];
|
||||
infrastructure: IntelFact[];
|
||||
cloud: IntelFact[];
|
||||
applications: IntelApp[];
|
||||
consumption: IntelConsumption[];
|
||||
deploymentRate: string;
|
||||
narratives: { heading: string; body: string }[];
|
||||
};
|
||||
|
||||
/** A candidate the importer suggests; nothing is written until a seller accepts. */
|
||||
export type IntelProposal = {
|
||||
key: string;
|
||||
/** What the dossier said, e.g. "Load Balancer Vendor: F5". */
|
||||
evidence: string;
|
||||
label: string;
|
||||
vendor: string;
|
||||
product: string;
|
||||
domain: string;
|
||||
productIds: string[];
|
||||
kbEntryId: string;
|
||||
confidence: "high" | "medium" | "low";
|
||||
capability: number;
|
||||
switchEase: number;
|
||||
timeToRoiMonths: number;
|
||||
savingsPct: number;
|
||||
migrationCost: number;
|
||||
risks: string;
|
||||
};
|
||||
|
||||
export type IntelImportResult = {
|
||||
intel: AccountIntel;
|
||||
proposals: IntelProposal[];
|
||||
/** Entitled but undeployed products — expansion plays, not displacements. */
|
||||
shelfware: IntelConsumption[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------ landscape questionnaire */
|
||||
|
||||
/** One competitive tool a respondent says is in place. */
|
||||
export type SurveyTool = {
|
||||
/** Stable per-answer key; the review inbox tracks accept/dismiss by it. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** Knowledge-base entry the respondent picked, "" for a typed-in tool. */
|
||||
kbEntryId: string;
|
||||
annualSpend: number | null;
|
||||
/** yyyy-mm, "" if unknown. */
|
||||
contractEnd: string;
|
||||
};
|
||||
|
||||
export type SurveyAnswer = {
|
||||
/** "" means the respondent has not answered this category yet. */
|
||||
status: "" | "tools" | "none" | "unsure";
|
||||
tools: SurveyTool[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A shareable, token-addressed questionnaire that captures an account's tool
|
||||
* landscape. Kept outside the assessment file so a respondent's autosave can
|
||||
* never race the seller's workbench autosave.
|
||||
*/
|
||||
export type Survey = {
|
||||
/** Unguessable token; it is the share link. */
|
||||
id: string;
|
||||
assessmentId: string;
|
||||
respondentName: string;
|
||||
/** Category (domain) ids, in the order the respondent steps through them. */
|
||||
domains: string[];
|
||||
answers: Record<string, SurveyAnswer>;
|
||||
status: "open" | "submitted";
|
||||
/** Seller's decision per tool key. Keys missing here are still pending review. */
|
||||
review: Record<string, "accepted" | "dismissed">;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
submittedAt: string;
|
||||
};
|
||||
|
||||
/** What the respondent's page receives: no internal data beyond the category list. */
|
||||
export type PublicSurvey = {
|
||||
id: string;
|
||||
customerName: string;
|
||||
respondentName: string;
|
||||
status: Survey["status"];
|
||||
answers: Survey["answers"];
|
||||
categories: { id: string; name: string; description: string; options: { id: string; label: string }[] }[];
|
||||
};
|
||||
207
start.sh
Executable file
207
start.sh
Executable file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Converge — start / stop / status
|
||||
#
|
||||
# Safe to run on a host with other Next.js apps. It never matches processes by
|
||||
# name: it only ever stops a PID it has confirmed is a Next server whose working
|
||||
# directory is this project. Another app on port 3000 (or anywhere else) is
|
||||
# never touched, and if this app's port is already taken by something else the
|
||||
# script reports it and stops rather than clearing the way.
|
||||
#
|
||||
# ./start.sh start in the background, wait until it answers
|
||||
# ./start.sh stop stop only this project's server
|
||||
# ./start.sh restart stop, then start
|
||||
# ./start.sh status is it running, and on what PID
|
||||
# ./start.sh logs follow the log
|
||||
# ./start.sh fg run in the foreground (Ctrl-C to quit)
|
||||
# ./start.sh build production build, then serve it
|
||||
#
|
||||
# PORT=3600 ./start.sh use a different port
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
PORT="${PORT:-3500}"
|
||||
RUN_DIR="$ROOT/.run"
|
||||
PID_FILE="$RUN_DIR/app-$PORT.pid"
|
||||
LOG_FILE="$RUN_DIR/app-$PORT.log"
|
||||
URL="http://localhost:$PORT"
|
||||
|
||||
bold=$'\033[1m'; dim=$'\033[2m'; red=$'\033[31m'; green=$'\033[32m'; yellow=$'\033[33m'; off=$'\033[0m'
|
||||
say() { printf '%s\n' "$*"; }
|
||||
ok() { printf '%s✓%s %s\n' "$green" "$off" "$*"; }
|
||||
warn() { printf '%s!%s %s\n' "$yellow" "$off" "$*"; }
|
||||
die() { printf '%s✗%s %s\n' "$red" "$off" "$*" >&2; exit 1; }
|
||||
|
||||
# --- identity checks -------------------------------------------------------
|
||||
# Everything below refuses to act on a process it cannot prove is ours.
|
||||
|
||||
# These helpers must succeed even when they find nothing: `lsof` and `ps` exit
|
||||
# non-zero on no-match, which under `set -euo pipefail` would abort the script
|
||||
# in the ordinary case of a free port.
|
||||
|
||||
# Working directory of a PID, or empty.
|
||||
proc_cwd() {
|
||||
{ lsof -a -p "$1" -d cwd -Fn 2>/dev/null || true; } | sed -n 's/^n//p' | head -1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Full command of a PID, or empty.
|
||||
proc_cmd() {
|
||||
ps -o command= -p "$1" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# True when the PID is a Next server running out of this project directory.
|
||||
is_our_app() {
|
||||
local pid="${1:-}"
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
case "$(proc_cmd "$pid")" in
|
||||
*next*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
[ "$(proc_cwd "$pid")" = "$ROOT" ]
|
||||
}
|
||||
|
||||
# PID listening on the port, or empty.
|
||||
port_pid() {
|
||||
{ lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true; } | head -1
|
||||
return 0
|
||||
}
|
||||
|
||||
# PID of our running server, adopting an already-running one if it is ours.
|
||||
running_pid() {
|
||||
local pid=""
|
||||
if [ -f "$PID_FILE" ]; then pid="$(cat "$PID_FILE" 2>/dev/null || true)"; fi
|
||||
if is_our_app "$pid"; then printf '%s' "$pid"; return 0; fi
|
||||
# No usable pid file — fall back to whoever holds the port, but only claim it
|
||||
# if it is demonstrably this project's server.
|
||||
pid="$(port_pid)"
|
||||
if is_our_app "$pid"; then printf '%s' "$pid"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- actions ---------------------------------------------------------------
|
||||
|
||||
ensure_deps() {
|
||||
command -v node >/dev/null || die "Node.js is not installed."
|
||||
command -v npm >/dev/null || die "npm is not installed."
|
||||
if [ ! -d node_modules ]; then
|
||||
say "${dim}Installing dependencies…${off}"
|
||||
npm install --no-audit --no-fund
|
||||
fi
|
||||
}
|
||||
|
||||
# Refuse to start if the port belongs to something that is not this app.
|
||||
check_port_free() {
|
||||
local pid; pid="$(port_pid)"
|
||||
[ -n "$pid" ] || return 0
|
||||
if is_our_app "$pid"; then return 0; fi
|
||||
printf '%s\n' "${red}✗${off} Port $PORT is in use by another program (PID $pid):" >&2
|
||||
printf ' %s\n' "$(proc_cmd "$pid")" >&2
|
||||
printf ' Leaving it alone. Start this app on a different port instead:\n' >&2
|
||||
printf ' %sPORT=3600 ./start.sh%s\n' "$bold" "$off" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_until_up() {
|
||||
local pid="$1" tries=90
|
||||
while [ $tries -gt 0 ]; do
|
||||
if curl -sf -o /dev/null "$URL/api/catalog"; then return 0; fi
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
sleep 1
|
||||
tries=$((tries - 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
do_start() {
|
||||
if pid="$(running_pid)"; then
|
||||
ok "Already running on $URL ${dim}(PID $pid)${off}"
|
||||
return 0
|
||||
fi
|
||||
ensure_deps
|
||||
check_port_free
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
say "${dim}Starting on port ${PORT}…${off}"
|
||||
# setsid-less background start; the PID we record is the one we may later stop.
|
||||
PORT="$PORT" nohup npx next dev -p "$PORT" > "$LOG_FILE" 2>&1 &
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
|
||||
if wait_until_up "$pid"; then
|
||||
ok "Converge is running"
|
||||
say " ${bold}$URL${off}"
|
||||
say " ${dim}logs: ./start.sh logs · stop: ./start.sh stop${off}"
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
say ""
|
||||
tail -20 "$LOG_FILE" >&2 || true
|
||||
die "It did not come up. Full log: $LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
do_fg() {
|
||||
ensure_deps
|
||||
check_port_free
|
||||
say "${dim}Starting on port $PORT — Ctrl-C to stop.${off}"
|
||||
exec npx next dev -p "$PORT"
|
||||
}
|
||||
|
||||
do_build() {
|
||||
ensure_deps
|
||||
check_port_free
|
||||
npx next build
|
||||
ok "Build complete — serving on $URL"
|
||||
exec npx next start -p "$PORT"
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
local pid
|
||||
if ! pid="$(running_pid)"; then
|
||||
rm -f "$PID_FILE"
|
||||
warn "Not running (nothing on port $PORT belonging to this project)."
|
||||
return 0
|
||||
fi
|
||||
# Stop the tree we started, without ever matching on process name.
|
||||
kill "$pid" 2>/dev/null || true
|
||||
local tries=15
|
||||
while [ $tries -gt 0 ] && kill -0 "$pid" 2>/dev/null; do sleep 0.4; tries=$((tries - 1)); done
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
warn "Did not exit cleanly; sending SIGKILL to PID $pid."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
ok "Stopped."
|
||||
}
|
||||
|
||||
do_status() {
|
||||
local pid
|
||||
if pid="$(running_pid)"; then
|
||||
ok "Running on $URL ${dim}(PID $pid)${off}"
|
||||
else
|
||||
local other; other="$(port_pid)"
|
||||
if [ -n "$other" ]; then
|
||||
warn "Not running. Port $PORT is held by another program (PID $other):"
|
||||
say " $(proc_cmd "$other")"
|
||||
else
|
||||
say "Not running. Port $PORT is free."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start) do_start ;;
|
||||
fg|dev) do_fg ;;
|
||||
build|prod) do_build ;;
|
||||
stop) do_stop ;;
|
||||
restart) do_stop; do_start ;;
|
||||
status) do_status ;;
|
||||
logs) [ -f "$LOG_FILE" ] || die "No log yet at $LOG_FILE"; tail -f "$LOG_FILE" ;;
|
||||
-h|--help|help) sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "Unknown command: $1 (try: start, stop, restart, status, logs, fg, build)" ;;
|
||||
esac
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user