diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..b2b5974
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,4 @@
+# Copy this file to .env and provide your private OpenAI API key.
+OPENAI_API_KEY=replace-with-your-openai-api-key
+# Optionally override the default model (defaults to gpt-4.1-mini)
+# OPENAI_MODEL=gpt-4.1-mini
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..916aa1a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.pyc
+.env
+node_modules/
+crm-ui/dist/
+.DS_Store
diff --git a/README.md b/README.md
index 3a08a72..2622eca 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,102 @@
-# beta
\ No newline at end of file
+# Insight Navigator CRM
+
+Insight Navigator CRM is a local-first revenue intelligence workspace that fuses data
+management, predictive analytics, risk mitigation, AI advisory, and document comparison
+workflows. The backend exposes a lightweight Flask API that brokers secure calls to your
+ChatGPT connection, while the front-end delivers an immersive CRM experience built with
+Vite + React.
+
+## Feature highlights
+
+- **Unified prediction engine** – blend revenue, expense, pipeline, and retention metrics to
+generate forward-looking guidance, confidence notes, and quarter-by-quarter outlooks.
+- **Data management diagnostics** – surface data quality gaps, integration opportunities, and
+curation actions from uploaded CRM exports or notes.
+- **Risk and compliance radar** – quantify risk appetite, exposure hotspots, and mitigation playbooks.
+- **AI advice console** – drive follow-up strategy conversations with context-aware prompts.
+- **Deep document comparison** – upload two strategic artefacts and receive AI-aligned
+alignment, conflict, and integration recommendations alongside structural diffs.
+- **Advanced React CRM shell** – responsive dashboards, rich charts, flexible layout primitives,
+and quick navigation across modules.
+
+## Prerequisites
+
+- Python 3.10+
+- Node.js 18+
+- An OpenAI API key with access to the Responses API (`OPENAI_API_KEY`)
+
+## Installation
+
+1. Clone the repository and move into the project directory.
+2. Run the Python dependency check (skips libraries that are already installed):
+
+ ```bash
+ python install.py
+ ```
+
+ On Windows you can execute `install.bat` for the same flow.
+
+3. Copy the environment template and add your OpenAI credentials:
+
+ ```bash
+ cp .env.example .env
+ # edit .env and set OPENAI_API_KEY (and optionally OPENAI_MODEL)
+ ```
+
+4. Install backend packages:
+
+ ```bash
+ python -m pip install -r requirements.txt
+ ```
+
+5. Install the React workspace dependencies:
+
+ ```bash
+ cd crm-ui
+ npm install
+ ```
+
+## Running the full stack
+
+In one terminal start the Flask API:
+
+```bash
+python app.py
+```
+
+In another terminal launch the Vite development server:
+
+```bash
+cd crm-ui
+npm run dev
+```
+
+By default the React app is served at http://localhost:5173 and proxies API calls to
+http://localhost:5000. Update `VITE_API_BASE_URL` in `.env` if you need a different host.
+
+## Environment variables
+
+The root `.env` file is read by the Flask backend:
+
+- `OPENAI_API_KEY` – required private key for the ChatGPT Responses API
+- `OPENAI_MODEL` – optional override (defaults to `gpt-4.1-mini`)
+
+The React client can be configured with `crm-ui/.env` using the same
+`VITE_API_BASE_URL` value shown in `.env.example`.
+
+## Project structure
+
+```
+app/ Flask blueprint and ChatGPT client helpers
+crm-ui/ Vite React CRM workspace
+ src/api/ Typed API clients and React Query hooks
+ src/components/ UI primitives used across CRM pages
+ src/pages/ Dashboard modules (prediction, risk, data, AI advisor, comparison)
+install.py Convenience installer for backend dependencies
+app.py Flask entry point
+```
+
+## Security
+
+The application only reads your API key from environment variables at runtime and never
+persists it. Do not commit real keys to version control.
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..7ea0616
--- /dev/null
+++ b/app.py
@@ -0,0 +1,7 @@
+from app import create_app
+
+app = create_app()
+
+
+if __name__ == '__main__':
+ app.run(host='0.0.0.0', port=5000, debug=True)
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..909eafb
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from dotenv import load_dotenv
+from flask import Flask
+from flask_cors import CORS
+
+from .routes import bp
+
+
+def create_app() -> Flask:
+ """Application factory for the CRM intelligence API."""
+ env_path = Path('.') / '.env'
+ if env_path.exists():
+ load_dotenv(env_path)
+
+ app = Flask(__name__)
+ app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5 MB payload cap
+
+ # Enable CORS so the Vite React workspace can communicate with the API during dev.
+ CORS(app, resources={r"/api/*": {"origins": "*"}})
+
+ logging.basicConfig(level=logging.INFO)
+
+ app.register_blueprint(bp)
+
+ return app
diff --git a/app/chatgpt_client.py b/app/chatgpt_client.py
new file mode 100644
index 0000000..4f664b6
--- /dev/null
+++ b/app/chatgpt_client.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+import json
+import os
+from typing import Any, Dict
+
+from openai import OpenAI
+
+
+class ChatGPTClient:
+ """Wrapper around the OpenAI Responses API tailored for CRM intelligence."""
+
+ def __init__(self) -> None:
+ api_key = os.getenv('OPENAI_API_KEY')
+ if not api_key:
+ raise RuntimeError(
+ 'OPENAI_API_KEY is not configured. Set it in your environment or .env file.'
+ )
+ self._client = OpenAI(api_key=api_key)
+ self._model = os.getenv('OPENAI_MODEL', 'gpt-4.1-mini')
+
+ def synthesize_crm_intelligence(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ """Generate CRM-ready insights across data, prediction, risk, and guidance."""
+ system_prompt = (
+ "You are a principal CRM strategist fusing data science with go-to-market leadership. "
+ "You receive structured telemetry about a customer portfolio and must produce an actionable "
+ "intelligence brief for revenue teams. Always respond with well-structured JSON that adheres "
+ "to the provided schema."
+ )
+ user_prompt = (
+ "Create an enterprise-grade briefing that includes sections for data management, predictive outlook, "
+ "risk posture, AI-powered recommendations, and CRM workflow actions. Anchor your commentary in the "
+ "metrics, document summaries, and comparison context supplied below."
+ f"\n\nPayload:\n{json.dumps(payload, indent=2)}"
+ )
+ request_arguments: Dict[str, Any] = {
+ 'model': self._model,
+ 'input': [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ 'temperature': 0.25,
+ 'max_output_tokens': 1100,
+ }
+ try:
+ response = self._client.responses.create(
+ **request_arguments,
+ response_format={"type": "json_schema", "json_schema": self._crm_schema()},
+ )
+ except TypeError:
+ response = self._client.responses.create(**request_arguments)
+ return self._parse_response(response)
+
+ def compare_documents(self, payload: Dict[str, Any]) -> Dict[str, Any]:
+ """Derive alignment and conflicts between two artefacts."""
+ system_prompt = (
+ "You are an enterprise transformation advisor reconciling two strategic documents. "
+ "Provide a candid comparison with highlights of convergence, divergence, and integration "
+ "opportunities. Return JSON that complies with the schema."
+ )
+ user_prompt = (
+ "Two documents plus optional focus guidance are provided. Produce a deep comparison, surfacing "
+ "key differences, risks, and recommended unifying actions."
+ f"\n\nPayload:\n{json.dumps(payload, indent=2)}"
+ )
+ request_arguments: Dict[str, Any] = {
+ 'model': self._model,
+ 'input': [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ 'temperature': 0.2,
+ 'max_output_tokens': 800,
+ }
+ try:
+ response = self._client.responses.create(
+ **request_arguments,
+ response_format={"type": "json_schema", "json_schema": self._comparison_schema()},
+ )
+ except TypeError:
+ response = self._client.responses.create(**request_arguments)
+ return self._parse_response(response)
+
+ def continue_conversation(self, conversation: str, prompt: str) -> str:
+ """Send a free-form prompt to ChatGPT for additional assistance."""
+ system_prompt = (
+ "You are a senior revenue strategist who references previous CRM analyses to answer follow-up "
+ "questions with precision. Provide structured, next-best-action advice."
+ )
+ user_prompt = (
+ "Historical context:\n"
+ f"{conversation}\n\n"
+ "New request:\n"
+ f"{prompt}"
+ )
+ response = self._client.responses.create(
+ model=self._model,
+ input=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt},
+ ],
+ temperature=0.35,
+ max_output_tokens=700,
+ )
+ return response.output_text.strip()
+
+ @staticmethod
+ def _crm_schema() -> Dict[str, Any]:
+ return {
+ "name": "crm_intelligence_report",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "data_management": {
+ "type": "object",
+ "properties": {
+ "quality_assessment": {"type": "string"},
+ "integration_opportunities": {"type": "string"},
+ "gaps": {"type": "string"},
+ },
+ "required": [
+ "quality_assessment",
+ "integration_opportunities",
+ "gaps",
+ ],
+ },
+ "predictive_outlook": {
+ "type": "object",
+ "properties": {
+ "growth_projection": {"type": "string"},
+ "leading_indicators": {"type": "string"},
+ "confidence": {"type": "string"},
+ "next_quarters": {"type": "string"},
+ },
+ "required": [
+ "growth_projection",
+ "leading_indicators",
+ "confidence",
+ "next_quarters",
+ ],
+ },
+ "risk_analysis": {
+ "type": "object",
+ "properties": {
+ "exposures": {"type": "string"},
+ "mitigations": {"type": "string"},
+ "heatmap": {"type": "string"},
+ },
+ "required": ["exposures", "mitigations", "heatmap"],
+ },
+ "ai_advice": {
+ "type": "object",
+ "properties": {
+ "strategic_guidance": {"type": "string"},
+ "tactical_steps": {"type": "string"},
+ "automation_ideas": {"type": "string"},
+ },
+ "required": [
+ "strategic_guidance",
+ "tactical_steps",
+ "automation_ideas",
+ ],
+ },
+ "crm_actions": {
+ "type": "object",
+ "properties": {
+ "priority_accounts": {"type": "string"},
+ "engagement_plan": {"type": "string"},
+ "workflow_enhancements": {"type": "string"},
+ },
+ "required": [
+ "priority_accounts",
+ "engagement_plan",
+ "workflow_enhancements",
+ ],
+ },
+ },
+ "required": [
+ "data_management",
+ "predictive_outlook",
+ "risk_analysis",
+ "ai_advice",
+ "crm_actions",
+ ],
+ "additionalProperties": False,
+ },
+ }
+
+ @staticmethod
+ def _comparison_schema() -> Dict[str, Any]:
+ return {
+ "name": "document_comparison",
+ "schema": {
+ "type": "object",
+ "properties": {
+ "alignment": {"type": "string"},
+ "contradictions": {"type": "string"},
+ "integration_paths": {"type": "string"},
+ "risk_flags": {"type": "string"},
+ },
+ "required": [
+ "alignment",
+ "contradictions",
+ "integration_paths",
+ "risk_flags",
+ ],
+ "additionalProperties": False,
+ },
+ }
+
+ @staticmethod
+ def _parse_response(response: Any) -> Dict[str, Any]:
+ raw_payload = _extract_response_text(response)
+ try:
+ parsed = json.loads(raw_payload)
+ except json.JSONDecodeError as exc:
+ raise RuntimeError('Unable to parse response from ChatGPT.') from exc
+ if not isinstance(parsed, dict):
+ raise RuntimeError('Unexpected response format from ChatGPT.')
+ return parsed
+
+
+def _extract_response_text(response: Any) -> str:
+ """Return the textual payload from an OpenAI Responses result."""
+
+ for attribute in ('output_text', 'text'):
+ value = getattr(response, attribute, None)
+ if isinstance(value, str) and value.strip():
+ return value
+
+ try:
+ return response.output[0].content[0].text # type: ignore[index]
+ except (AttributeError, IndexError, KeyError) as exc:
+ raise RuntimeError('Unable to read response payload from ChatGPT.') from exc
diff --git a/app/routes.py b/app/routes.py
new file mode 100644
index 0000000..855ea52
--- /dev/null
+++ b/app/routes.py
@@ -0,0 +1,286 @@
+from __future__ import annotations
+
+import csv
+import difflib
+import io
+import itertools
+import json
+from pathlib import Path
+from typing import Any, Dict, List, Tuple
+
+from flask import Blueprint, jsonify, request
+
+from .chatgpt_client import ChatGPTClient
+
+bp = Blueprint('api', __name__, url_prefix='/api')
+
+
+@bp.get('/health')
+def health() -> Any:
+ return jsonify({"status": "ok"})
+
+
+@bp.post('/predict')
+def predict() -> Any:
+ payload: Dict[str, Any] = request.get_json(silent=True) or {}
+ scenario: Dict[str, Any] = payload.get('scenario') or {}
+ documents: List[Dict[str, Any]] = payload.get('documents') or []
+ comparison: Dict[str, Any] = payload.get('comparison') or {}
+
+ summaries = [_summarize_document(doc) for doc in documents][:4]
+ metrics = _calculate_metrics(scenario)
+
+ try:
+ client = ChatGPTClient()
+ report = client.synthesize_crm_intelligence(
+ {
+ 'scenario': scenario,
+ 'metrics': metrics,
+ 'documents': summaries,
+ 'comparison': comparison,
+ }
+ )
+ except RuntimeError as exc:
+ return jsonify({'error': str(exc)}), 500
+
+ return jsonify(
+ {
+ 'report': report,
+ 'documentSummaries': summaries,
+ 'metrics': metrics,
+ }
+ )
+
+
+@bp.post('/chat')
+def chat() -> Any:
+ payload: Dict[str, Any] = request.get_json(silent=True) or {}
+ conversation: str = payload.get('conversation', '')
+ prompt: str = payload.get('prompt', '').strip()
+
+ if not prompt:
+ return jsonify({'error': 'Prompt is required.'}), 400
+
+ try:
+ client = ChatGPTClient()
+ response_text = client.continue_conversation(conversation, prompt)
+ except RuntimeError as exc:
+ return jsonify({'error': str(exc)}), 500
+
+ return jsonify({'response': response_text})
+
+
+@bp.post('/compare')
+def compare() -> Any:
+ payload: Dict[str, Any] = request.get_json(silent=True) or {}
+ documents: List[Dict[str, Any]] = payload.get('documents') or []
+ focus: str = payload.get('focus', '')
+
+ if len(documents) != 2:
+ return jsonify({'error': 'Provide exactly two documents for comparison.'}), 400
+
+ normalized = [_summarize_document(doc) for doc in documents]
+ diff = _diff_documents(documents[0].get('content', ''), documents[1].get('content', ''))
+
+ try:
+ client = ChatGPTClient()
+ analysis = client.compare_documents({
+ 'documents': normalized,
+ 'focus': focus,
+ })
+ except RuntimeError as exc:
+ return jsonify({'error': str(exc)}), 500
+
+ return jsonify({'analysis': analysis, 'diff': diff, 'documents': normalized})
+
+
+def _calculate_metrics(scenario: Dict[str, Any]) -> Dict[str, Any]:
+ revenue = scenario.get('revenue') or {}
+ expenses = scenario.get('expenses') or {}
+ pipeline = scenario.get('pipeline') or {}
+ retention = scenario.get('retention') or {}
+ kpis = scenario.get('kpis') or []
+
+ revenue_current = _safe_float(revenue.get('current'))
+ revenue_target = _safe_float(revenue.get('projected'))
+ expenses_current = _safe_float(expenses.get('current'))
+ expenses_target = _safe_float(expenses.get('projected'))
+
+ weighted_pipeline = _safe_float(pipeline.get('weightedPipeline'))
+ win_rate = _safe_float(pipeline.get('winRate'))
+ avg_deal_size = _safe_float(pipeline.get('avgDealSize'))
+ churn_rate = _safe_float(retention.get('churnRate'))
+ expansion_rate = _safe_float(retention.get('expansionRate'))
+
+ metrics = {
+ 'growth': {
+ 'revenueDelta': _format_delta(revenue_current, revenue_target),
+ 'expenseDelta': _format_delta(expenses_current, expenses_target, invert=True),
+ },
+ 'efficiency': {
+ 'operatingMargin': _ratio(revenue_target - expenses_target, revenue_target),
+ 'runway': scenario.get('runwayMonths'),
+ },
+ 'pipeline': {
+ 'weightedValue': weighted_pipeline,
+ 'winRate': win_rate,
+ 'avgDealSize': avg_deal_size,
+ },
+ 'retention': {
+ 'churnRate': churn_rate,
+ 'expansionRate': expansion_rate,
+ 'netRevenueRetention': _ratio(100 + expansion_rate - churn_rate, 100),
+ },
+ 'kpis': [
+ {
+ 'label': item.get('label'),
+ 'current': _safe_float(item.get('current')),
+ 'target': _safe_float(item.get('target')),
+ 'unit': item.get('unit', ''),
+ 'progress': _progress(item.get('current'), item.get('target')),
+ }
+ for item in kpis
+ if item.get('label')
+ ],
+ }
+
+ metrics['riskAppetite'] = scenario.get('riskAppetite', 3)
+ metrics['horizon'] = scenario.get('timeHorizon')
+
+ return metrics
+
+
+def _progress(current: Any, target: Any) -> float:
+ current_val = _safe_float(current)
+ target_val = _safe_float(target)
+ if target_val == 0:
+ return 0.0
+ return max(0.0, min(1.0, current_val / target_val))
+
+
+def _ratio(numerator: float, denominator: float) -> float:
+ if denominator == 0:
+ return 0.0
+ return round(numerator / denominator, 4)
+
+
+def _format_delta(current: float, target: float, invert: bool = False) -> Dict[str, Any]:
+ delta = target - current
+ direction = 'up' if delta >= 0 else 'down'
+ if invert:
+ direction = 'up' if delta <= 0 else 'down'
+ percent_change = 0.0
+ if current:
+ percent_change = round(abs(delta) / abs(current), 4)
+ return {
+ 'current': current,
+ 'target': target,
+ 'delta': round(delta, 4),
+ 'direction': direction,
+ 'percentChange': percent_change,
+ }
+
+
+def _safe_float(value: Any) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+def _summarize_document(document: Dict[str, Any]) -> Dict[str, Any]:
+ filename = document.get('filename') or 'document'
+ content = document.get('content', '')
+ extension = Path(filename).suffix.lower()
+
+ if extension == '.csv':
+ preview, summary = _summarize_csv(content)
+ elif extension == '.json':
+ preview, summary = _summarize_json(content)
+ else:
+ preview, summary = _summarize_text(content)
+
+ return {
+ 'filename': filename,
+ 'format': extension.lstrip('.') or 'text',
+ 'summary': summary,
+ 'excerpt': preview[:1200],
+ 'length': len(content.splitlines()),
+ }
+
+
+def _summarize_csv(text: str) -> Tuple[str, str]:
+ buffer = io.StringIO(text)
+ reader = csv.reader(buffer)
+ rows = list(itertools.islice(reader, 0, 11))
+ if not rows:
+ return '', 'CSV file with no rows detected.'
+
+ header = rows[0]
+ body_rows = rows[1:]
+ row_count = len(body_rows)
+ preview_rows = [header] + body_rows[:5]
+ preview = '\n'.join(', '.join(cell.strip() for cell in row) for row in preview_rows)
+ summary = (
+ f"CSV dataset with {row_count} data rows. Columns: "
+ + ', '.join(header[:6])
+ + ('...' if len(header) > 6 else '')
+ )
+ return preview, summary
+
+
+def _summarize_json(text: str) -> Tuple[str, str]:
+ try:
+ parsed = json.loads(text)
+ except json.JSONDecodeError:
+ return _summarize_text(text)
+
+ preview = json.dumps(parsed, indent=2)[:1200]
+ if isinstance(parsed, dict):
+ keys = list(parsed.keys())
+ summary = f"JSON document with {len(keys)} top-level keys: {', '.join(keys[:6])}" + (
+ '...' if len(keys) > 6 else ''
+ )
+ elif isinstance(parsed, list):
+ summary = f"JSON array with {len(parsed)} entries."
+ else:
+ summary = f"JSON value of type {type(parsed).__name__}."
+ return preview, summary
+
+
+def _summarize_text(text: str) -> Tuple[str, str]:
+ stripped = (text or '').strip()
+ words = [word for word in stripped.replace('\n', ' ').split(' ') if word]
+ summary = (
+ f"Narrative brief with approximately {len(words)} words."
+ if words
+ else 'Text document.'
+ )
+ preview = stripped[:1200]
+ return preview, summary
+
+
+def _diff_documents(source: str, target: str) -> Dict[str, Any]:
+ source_lines = source.splitlines()
+ target_lines = target.splitlines()
+ matcher = difflib.SequenceMatcher(None, source_lines, target_lines)
+
+ changes = []
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == 'equal':
+ continue
+ changes.append(
+ {
+ 'type': tag,
+ 'sourceRange': [i1, i2],
+ 'targetRange': [j1, j2],
+ 'sourceExcerpt': '\n'.join(source_lines[i1:i2])[:300],
+ 'targetExcerpt': '\n'.join(target_lines[j1:j2])[:300],
+ }
+ )
+
+ return {
+ 'similarity': round(matcher.quick_ratio(), 4),
+ 'changeCount': len(changes),
+ 'changes': changes,
+ }
diff --git a/crm-ui/.env.example b/crm-ui/.env.example
new file mode 100644
index 0000000..64c0b37
--- /dev/null
+++ b/crm-ui/.env.example
@@ -0,0 +1 @@
+VITE_API_BASE_URL=http://localhost:5000/api
diff --git a/crm-ui/index.html b/crm-ui/index.html
new file mode 100644
index 0000000..bf9a659
--- /dev/null
+++ b/crm-ui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Insight Navigator CRM
+
+
+
+
+
+
diff --git a/crm-ui/package.json b/crm-ui/package.json
new file mode 100644
index 0000000..ac1e645
--- /dev/null
+++ b/crm-ui/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "insight-navigator-crm",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.11.4",
+ "@emotion/styled": "^11.11.5",
+ "@mui/icons-material": "^5.15.15",
+ "@mui/lab": "^5.0.0-alpha.170",
+ "@mui/material": "^5.15.15",
+ "@tanstack/react-query": "^5.39.1",
+ "axios": "^1.6.7",
+ "papaparse": "^5.4.1",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.22.3",
+ "recharts": "^2.7.2"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.21",
+ "@types/react-dom": "^18.2.7",
+ "@vitejs/plugin-react": "^4.2.1",
+ "typescript": "^5.4.3",
+ "vite": "^5.1.0"
+ }
+}
diff --git a/crm-ui/src/App.tsx b/crm-ui/src/App.tsx
new file mode 100644
index 0000000..50bf354
--- /dev/null
+++ b/crm-ui/src/App.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { BrowserRouter, Route, Routes } from 'react-router-dom';
+
+import LayoutShell from './components/LayoutShell';
+import Dashboard from './pages/Dashboard';
+import DataManagement from './pages/DataManagement';
+import PredictiveIntelligence from './pages/PredictiveIntelligence';
+import RiskCommand from './pages/RiskCommand';
+import AdvisorConsole from './pages/AdvisorConsole';
+import ComparisonLab from './pages/ComparisonLab';
+import NotFound from './pages/NotFound';
+
+const App: React.FC = () => (
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+);
+
+export default App;
diff --git a/crm-ui/src/api/client.ts b/crm-ui/src/api/client.ts
new file mode 100644
index 0000000..a4e5736
--- /dev/null
+++ b/crm-ui/src/api/client.ts
@@ -0,0 +1,10 @@
+import axios from 'axios';
+
+const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api';
+
+export const apiClient = axios.create({
+ baseURL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
diff --git a/crm-ui/src/api/hooks.ts b/crm-ui/src/api/hooks.ts
new file mode 100644
index 0000000..26efe41
--- /dev/null
+++ b/crm-ui/src/api/hooks.ts
@@ -0,0 +1,43 @@
+import { useMutation } from '@tanstack/react-query';
+
+import { apiClient } from './client';
+import {
+ ComparisonResponse,
+ DocumentUpload,
+ PredictionResponse,
+ ScenarioPayload,
+} from '../types';
+
+interface PredictRequest {
+ scenario: ScenarioPayload;
+ documents: DocumentUpload[];
+ comparison?: Record;
+}
+
+interface ChatRequest {
+ conversation: string;
+ prompt: string;
+}
+
+interface CompareRequest {
+ documents: DocumentUpload[];
+ focus: string;
+}
+
+export const usePredict = () =>
+ useMutation(async (payload) => {
+ const { data } = await apiClient.post('/predict', payload);
+ return data;
+ });
+
+export const useChat = () =>
+ useMutation(async (payload) => {
+ const { data } = await apiClient.post<{ response: string }>('/chat', payload);
+ return data.response;
+ });
+
+export const useCompare = () =>
+ useMutation(async (payload) => {
+ const { data } = await apiClient.post('/compare', payload);
+ return data;
+ });
diff --git a/crm-ui/src/components/ComparisonDiff.tsx b/crm-ui/src/components/ComparisonDiff.tsx
new file mode 100644
index 0000000..ca9591f
--- /dev/null
+++ b/crm-ui/src/components/ComparisonDiff.tsx
@@ -0,0 +1,48 @@
+import { Card, CardContent, Chip, Grid, Stack, Typography } from '@mui/material';
+import DifferenceIcon from '@mui/icons-material/DifferenceOutlined';
+import React from 'react';
+
+import { DiffSummary } from '../types';
+
+export const ComparisonDiff: React.FC<{ diff: DiffSummary | undefined }> = ({ diff }) => {
+ if (!diff) {
+ return null;
+ }
+ return (
+
+
+
+ Change log
+ }
+ label={`Similarity ${(diff.similarity * 100).toFixed(1)}%`}
+ color="secondary"
+ variant="outlined"
+ />
+
+
+ {diff.changes.map((change, index) => (
+
+
+
+ {change.type.toUpperCase()} • source {change.sourceRange[0]}–{change.sourceRange[1]}
+
+
+ {change.sourceExcerpt || '—'}
+
+
+ target {change.targetRange[0]}–{change.targetRange[1]}
+
+
+ {change.targetExcerpt || '—'}
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default ComparisonDiff;
diff --git a/crm-ui/src/components/DocumentSummaryCard.tsx b/crm-ui/src/components/DocumentSummaryCard.tsx
new file mode 100644
index 0000000..726d180
--- /dev/null
+++ b/crm-ui/src/components/DocumentSummaryCard.tsx
@@ -0,0 +1,30 @@
+import { Card, CardContent, Chip, Typography } from '@mui/material';
+import DescriptionIcon from '@mui/icons-material/DescriptionOutlined';
+import React from 'react';
+
+import { DocumentSummary } from '../types';
+
+export const DocumentSummaryCard: React.FC<{ summary: DocumentSummary }> = ({ summary }) => (
+
+
+ }
+ label={summary.filename}
+ color="primary"
+ variant="outlined"
+ sx={{ mb: 2 }}
+ />
+
+ {summary.summary}
+
+
+ {summary.length} lines • {summary.format.toUpperCase()}
+
+
+ {summary.excerpt}
+
+
+
+);
+
+export default DocumentSummaryCard;
diff --git a/crm-ui/src/components/ErrorBanner.tsx b/crm-ui/src/components/ErrorBanner.tsx
new file mode 100644
index 0000000..d3dd4e4
--- /dev/null
+++ b/crm-ui/src/components/ErrorBanner.tsx
@@ -0,0 +1,11 @@
+import { Alert } from '@mui/material';
+import React from 'react';
+
+export const ErrorBanner: React.FC<{ error?: string }> = ({ error }) => {
+ if (!error) {
+ return null;
+ }
+ return {error};
+};
+
+export default ErrorBanner;
diff --git a/crm-ui/src/components/InsightsPanel.tsx b/crm-ui/src/components/InsightsPanel.tsx
new file mode 100644
index 0000000..b40e781
--- /dev/null
+++ b/crm-ui/src/components/InsightsPanel.tsx
@@ -0,0 +1,38 @@
+import { Card, CardContent, Grid, Typography } from '@mui/material';
+import React from 'react';
+
+import { CRMReport } from '../types';
+
+export const InsightsPanel: React.FC<{ report?: CRMReport }> = ({ report }) => {
+ if (!report) {
+ return null;
+ }
+
+ return (
+
+ {Object.entries(report).map(([sectionKey, value]) => (
+
+
+
+
+ {sectionKey.replace('_', ' ')}
+
+ {Object.entries(value).map(([key, text]) => (
+
+
+ {key.replace('_', ' ')}
+
+
+ {text}
+
+
+ ))}
+
+
+
+ ))}
+
+ );
+};
+
+export default InsightsPanel;
diff --git a/crm-ui/src/components/LayoutShell.tsx b/crm-ui/src/components/LayoutShell.tsx
new file mode 100644
index 0000000..0f8a933
--- /dev/null
+++ b/crm-ui/src/components/LayoutShell.tsx
@@ -0,0 +1,16 @@
+import { Box, Container } from '@mui/material';
+import React from 'react';
+import { Sidebar } from './Sidebar';
+
+export const LayoutShell: React.FC = ({ children }) => (
+
+
+
+
+ {children}
+
+
+
+);
+
+export default LayoutShell;
diff --git a/crm-ui/src/components/LoadingState.tsx b/crm-ui/src/components/LoadingState.tsx
new file mode 100644
index 0000000..f0acce3
--- /dev/null
+++ b/crm-ui/src/components/LoadingState.tsx
@@ -0,0 +1,13 @@
+import { Box, CircularProgress, Typography } from '@mui/material';
+import React from 'react';
+
+export const LoadingState: React.FC<{ message?: string }> = ({ message = 'Processing with AI...' }) => (
+
+
+
+ {message}
+
+
+);
+
+export default LoadingState;
diff --git a/crm-ui/src/components/MetricCards.tsx b/crm-ui/src/components/MetricCards.tsx
new file mode 100644
index 0000000..3d6e6e2
--- /dev/null
+++ b/crm-ui/src/components/MetricCards.tsx
@@ -0,0 +1,146 @@
+import {
+ Card,
+ CardContent,
+ Grid,
+ LinearProgress,
+ Stack,
+ Typography,
+} from '@mui/material';
+import TrendingUpIcon from '@mui/icons-material/TrendingUp';
+import TrendingDownIcon from '@mui/icons-material/TrendingDown';
+import InsightsIcon from '@mui/icons-material/Insights';
+import React from 'react';
+
+import { Delta, MetricsBundle } from '../types';
+
+interface MetricCardsProps {
+ metrics?: MetricsBundle;
+}
+
+const DeltaCard: React.FC<{ title: string; delta: Delta; positiveIsUp?: boolean }> = ({
+ title,
+ delta,
+ positiveIsUp = true,
+}) => {
+ const isPositive = delta.direction === 'up';
+ const icon = isPositive === positiveIsUp ? : ;
+ const accent = isPositive === positiveIsUp ? 'success.main' : 'error.main';
+ return (
+
+
+
+
+
+ {title}
+
+
+ {delta.target.toLocaleString(undefined, { maximumFractionDigits: 1 })}
+
+
+ Current: {delta.current.toLocaleString(undefined, { maximumFractionDigits: 1 })}
+
+
+ Δ {delta.delta.toLocaleString(undefined, { maximumFractionDigits: 2 })} ({
+ (delta.percentChange * 100).toFixed(1)
+ }
+ %)
+
+
+ {icon}
+
+
+
+ );
+};
+
+export const MetricCards: React.FC = ({ metrics }) => {
+ if (!metrics) {
+ return null;
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Weighted pipeline value
+
+
+ ${metrics.pipeline.weightedValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+
+ Win rate {metrics.pipeline.winRate.toFixed(1)}% • Avg deal ${metrics.pipeline.avgDealSize.toFixed(0)}
+
+
+
+
+
+
+
+
+ Operating margin outlook
+
+
+
+ {(metrics.efficiency.operatingMargin * 100).toFixed(1)}%
+
+ {metrics.efficiency.runway && (
+
+ Cash runway ≈ {metrics.efficiency.runway} months
+
+ )}
+
+
+
+
+
+
+
+ Net revenue retention
+
+
+ {(metrics.retention.netRevenueRetention * 100).toFixed(1)}%
+
+
+ Churn {metrics.retention.churnRate.toFixed(1)}% • Expansion {metrics.retention.expansionRate.toFixed(1)}%
+
+
+
+
+
+
+
+
+ KPI progress
+
+
+ {metrics.kpis.map((kpi) => (
+
+
+ {kpi.label}
+
+ {kpi.current} / {kpi.target} {kpi.unit}
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default MetricCards;
diff --git a/crm-ui/src/components/Sidebar.tsx b/crm-ui/src/components/Sidebar.tsx
new file mode 100644
index 0000000..c19646a
--- /dev/null
+++ b/crm-ui/src/components/Sidebar.tsx
@@ -0,0 +1,66 @@
+import { List, ListItemButton, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material';
+import { useLocation, Link as RouterLink } from 'react-router-dom';
+import DashboardIcon from '@mui/icons-material/SpaceDashboardOutlined';
+import InsightsIcon from '@mui/icons-material/AnalyticsOutlined';
+import RiskIcon from '@mui/icons-material/WarningAmberOutlined';
+import ChatIcon from '@mui/icons-material/ForumOutlined';
+import CompareIcon from '@mui/icons-material/CompareArrowsOutlined';
+import DataIcon from '@mui/icons-material/DatasetOutlined';
+
+const items = [
+ { label: 'Overview', icon: , path: '/' },
+ { label: 'Data Management', icon: , path: '/data' },
+ { label: 'Predictive Intelligence', icon: , path: '/predict' },
+ { label: 'Risk Command', icon: , path: '/risk' },
+ { label: 'AI Advisor', icon: , path: '/advisor' },
+ { label: 'Comparison Lab', icon: , path: '/compare' },
+];
+
+export const Sidebar = () => {
+ const location = useLocation();
+ return (
+
+
+ Insight Navigator
+
+
+ {items.map((item) => {
+ const selected = location.pathname === item.path;
+ return (
+
+ {item.icon}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/crm-ui/src/components/Topbar.tsx b/crm-ui/src/components/Topbar.tsx
new file mode 100644
index 0000000..00dbd1d
--- /dev/null
+++ b/crm-ui/src/components/Topbar.tsx
@@ -0,0 +1,55 @@
+import { Box, Breadcrumbs, Button, Link, Stack, Typography } from '@mui/material';
+import NavigateNextIcon from '@mui/icons-material/NavigateNext';
+import TrendingUpIcon from '@mui/icons-material/TrendingUp';
+import { Link as RouterLink } from 'react-router-dom';
+import React from 'react';
+
+interface TopbarProps {
+ title: string;
+ subtitle?: string;
+ breadcrumb?: Array<{ label: string; to?: string }>;
+ onPredictClick?: () => void;
+}
+
+export const Topbar: React.FC = ({ title, subtitle, breadcrumb, onPredictClick }) => (
+
+
+ {breadcrumb && (
+ } sx={{ mb: 1 }}>
+ {breadcrumb.map((item) =>
+ item.to ? (
+
+ {item.label}
+
+ ) : (
+
+ {item.label}
+
+ ),
+ )}
+
+ )}
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {onPredictClick && (
+ }
+ onClick={onPredictClick}
+ >
+ Run new prediction
+
+ )}
+
+);
+
+export default Topbar;
diff --git a/crm-ui/src/main.tsx b/crm-ui/src/main.tsx
new file mode 100644
index 0000000..f6fc731
--- /dev/null
+++ b/crm-ui/src/main.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { CssBaseline, ThemeProvider } from '@mui/material';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+import App from './App';
+import theme from './theme';
+import { AssessmentProvider } from './store/AssessmentContext';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ staleTime: 1000 * 60,
+ },
+ },
+});
+
+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/crm-ui/src/pages/AdvisorConsole.tsx b/crm-ui/src/pages/AdvisorConsole.tsx
new file mode 100644
index 0000000..3566e44
--- /dev/null
+++ b/crm-ui/src/pages/AdvisorConsole.tsx
@@ -0,0 +1,119 @@
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+import SendIcon from '@mui/icons-material/Send';
+import React, { useMemo, useState } from 'react';
+
+import Topbar from '../components/Topbar';
+import ErrorBanner from '../components/ErrorBanner';
+import LoadingState from '../components/LoadingState';
+import { useChat } from '../api/hooks';
+import { useAssessment } from '../store/AssessmentContext';
+
+const AdvisorConsole: React.FC = () => {
+ const { prediction, scenario, transcript, appendTranscript } = useAssessment();
+ const [prompt, setPrompt] = useState('');
+ const [error, setError] = useState();
+ const { mutateAsync, isPending } = useChat();
+
+ if (!prediction) {
+ return (
+
+
+
+
+
+ Generate an intelligence report to unlock contextual conversations.
+
+
+
+
+ );
+ }
+
+ const conversationSeed = useMemo(() => {
+ if (!prediction) return '';
+ return JSON.stringify(
+ {
+ scenario,
+ report: prediction.report,
+ },
+ null,
+ 2,
+ );
+ }, [prediction, scenario]);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError(undefined);
+ if (!prompt.trim()) {
+ setError('Enter a question or request for the advisor.');
+ return;
+ }
+ try {
+ const reply = await mutateAsync({ conversation: `${conversationSeed}\n${transcript}`, prompt });
+ appendTranscript(`User: ${prompt}\nAdvisor: ${reply}`);
+ setPrompt('');
+ } catch (err) {
+ setError((err as Error).message);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ setPrompt(event.target.value)}
+ multiline
+ minRows={4}
+ />
+
+ } disabled={isPending}>
+ {isPending ? 'Engaging…' : 'Send to advisor'}
+
+
+
+
+
+
+ {isPending && }
+
+ {transcript && (
+
+
+
+ Conversation log
+
+
+ {transcript}
+
+
+
+ )}
+
+ );
+};
+
+export default AdvisorConsole;
diff --git a/crm-ui/src/pages/ComparisonLab.tsx b/crm-ui/src/pages/ComparisonLab.tsx
new file mode 100644
index 0000000..c6fb240
--- /dev/null
+++ b/crm-ui/src/pages/ComparisonLab.tsx
@@ -0,0 +1,158 @@
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Grid,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+import CompareIcon from '@mui/icons-material/CompareOutlined';
+import React, { useState } from 'react';
+
+import Topbar from '../components/Topbar';
+import LoadingState from '../components/LoadingState';
+import ErrorBanner from '../components/ErrorBanner';
+import DocumentSummaryCard from '../components/DocumentSummaryCard';
+import ComparisonDiff from '../components/ComparisonDiff';
+import { useCompare } from '../api/hooks';
+import { DocumentUpload } from '../types';
+import { useAssessment } from '../store/AssessmentContext';
+
+const ComparisonLab: React.FC = () => {
+ const { comparison, setComparison } = useAssessment();
+ const [focus, setFocus] = useState('');
+ const [error, setError] = useState();
+ const [documents, setDocuments] = useState([]);
+ const { mutateAsync, isPending } = useCompare();
+
+ const handleUpload = async (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ if (!files) return;
+ const pair: DocumentUpload[] = [];
+ for (const file of Array.from(files).slice(0, 2)) {
+ const content = await file.text();
+ pair.push({ filename: file.name, content });
+ }
+ setDocuments(pair);
+ setComparison(undefined);
+ };
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError(undefined);
+ if (documents.length !== 2) {
+ setError('Upload two documents to run a comparison.');
+ return;
+ }
+ try {
+ const response = await mutateAsync({ documents, focus });
+ setComparison(response);
+ } catch (err) {
+ setError((err as Error).message);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ }>
+ Select two documents
+
+
+ setFocus(event.target.value)}
+ multiline
+ minRows={3}
+ />
+
+
+
+
+
+
+
+ {documents.length > 0 && (
+
+ {documents.map((doc) => (
+
+
+
+ ))}
+
+ )}
+
+ {isPending && }
+
+ {comparison && comparison.documents && (
+
+ {comparison.documents.map((doc) => (
+
+
+
+ ))}
+
+ )}
+
+ {comparison && (
+
+
+
+ Alignment findings
+
+ Alignment
+
+
+ {comparison.analysis.alignment}
+
+
+ Contradictions
+
+
+ {comparison.analysis.contradictions}
+
+
+ Integration paths
+
+
+ {comparison.analysis.integration_paths}
+
+
+ Risk flags
+
+
+ {comparison.analysis.risk_flags}
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ComparisonLab;
diff --git a/crm-ui/src/pages/Dashboard.tsx b/crm-ui/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..4e7e7d1
--- /dev/null
+++ b/crm-ui/src/pages/Dashboard.tsx
@@ -0,0 +1,62 @@
+import { Grid, Paper, Stack, Typography, Button } from '@mui/material';
+import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import Topbar from '../components/Topbar';
+import MetricCards from '../components/MetricCards';
+import InsightsPanel from '../components/InsightsPanel';
+import DocumentSummaryCard from '../components/DocumentSummaryCard';
+import { useAssessment } from '../store/AssessmentContext';
+
+const Dashboard: React.FC = () => {
+ const navigate = useNavigate();
+ const { prediction, metrics } = useAssessment();
+
+ const hasPrediction = Boolean(prediction && metrics);
+
+ return (
+
+ navigate('/predict')}
+ />
+
+ {!hasPrediction ? (
+
+
+ Begin with a prediction run
+
+
+ Upload your CRM extracts or enter core metrics to unlock AI-driven forecasts, risk analysis, and
+ alignment guidance.
+
+ } onClick={() => navigate('/predict')}>
+ Launch predictive workspace
+
+
+ ) : (
+ <>
+
+
+ {prediction?.documentSummaries?.length ? (
+
+ Document intelligence
+
+ {prediction.documentSummaries.map((summary) => (
+
+
+
+ ))}
+
+
+ ) : null}
+ >
+ )}
+
+ );
+};
+
+export default Dashboard;
diff --git a/crm-ui/src/pages/DataManagement.tsx b/crm-ui/src/pages/DataManagement.tsx
new file mode 100644
index 0000000..8d9b4cd
--- /dev/null
+++ b/crm-ui/src/pages/DataManagement.tsx
@@ -0,0 +1,158 @@
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Grid,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography,
+} from '@mui/material';
+import UploadIcon from '@mui/icons-material/UploadFile';
+import DeleteIcon from '@mui/icons-material/DeleteOutline';
+import Papa from 'papaparse';
+import React, { useState } from 'react';
+
+import Topbar from '../components/Topbar';
+import { DocumentUpload } from '../types';
+import { useAssessment } from '../store/AssessmentContext';
+import DocumentSummaryCard from '../components/DocumentSummaryCard';
+
+interface PreviewData {
+ filename: string;
+ rows: string[][];
+}
+
+const DataManagement: React.FC = () => {
+ const { documents, setDocuments, dataNotes, setDataNotes } = useAssessment();
+ const [previews, setPreviews] = useState([]);
+
+ const handleUpload = async (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ if (!files) return;
+
+ const uploads: DocumentUpload[] = [];
+ const previewRows: PreviewData[] = [];
+
+ for (const file of Array.from(files).slice(0, 3)) {
+ const content = await file.text();
+ uploads.push({ filename: file.name, content });
+
+ if (file.name.toLowerCase().endsWith('.csv')) {
+ const parsed = Papa.parse(content, { preview: 8 });
+ const rows = (parsed.data as string[][]).filter((row) => row.some((cell) => cell));
+ previewRows.push({ filename: file.name, rows: rows.slice(0, 6) });
+ } else {
+ const snippet = content.split('\n').slice(0, 6).map((line) => [line]);
+ previewRows.push({ filename: file.name, rows: snippet });
+ }
+ }
+
+ setDocuments(uploads);
+ setPreviews(previewRows);
+ };
+
+ const clearUploads = () => {
+ setDocuments([]);
+ setPreviews([]);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Upload up to three supporting documents
+
+
+ CSV, JSON, or TXT formats work best. Content is streamed locally and passed to the backend for
+ secure AI analysis.
+
+
+ }>
+ Select files
+
+
+ {documents.length > 0 && (
+ } onClick={clearUploads}>
+ Clear uploads
+
+ )}
+ setDataNotes(event.target.value)}
+ multiline
+ minRows={4}
+ fullWidth
+ />
+
+
+
+
+
+
+ {documents.map((doc) => (
+
+ ))}
+
+
+
+
+ {previews.length > 0 && (
+
+ Quick previews
+ {previews.map((preview) => (
+
+
+
+ {preview.filename}
+
+
+
+
+ Row
+ Content
+
+
+
+ {preview.rows.map((row, index) => (
+
+ {index + 1}
+
+ {row.join(', ').slice(0, 280)}
+ {row.join(', ').length > 280 ? '…' : ''}
+
+
+ ))}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default DataManagement;
diff --git a/crm-ui/src/pages/NotFound.tsx b/crm-ui/src/pages/NotFound.tsx
new file mode 100644
index 0000000..55bc920
--- /dev/null
+++ b/crm-ui/src/pages/NotFound.tsx
@@ -0,0 +1,25 @@
+import { Button, Paper, Stack, Typography } from '@mui/material';
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import Topbar from '../components/Topbar';
+
+const NotFound: React.FC = () => {
+ const navigate = useNavigate();
+ return (
+
+
+
+
+ We lost this route in the CRM maze.
+
+
+ Use the navigation menu to jump back into the intelligence workspace.
+
+
+
+
+ );
+};
+
+export default NotFound;
diff --git a/crm-ui/src/pages/PredictiveIntelligence.tsx b/crm-ui/src/pages/PredictiveIntelligence.tsx
new file mode 100644
index 0000000..9133a7f
--- /dev/null
+++ b/crm-ui/src/pages/PredictiveIntelligence.tsx
@@ -0,0 +1,368 @@
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Grid,
+ Slider,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+import React, { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import Topbar from '../components/Topbar';
+import ErrorBanner from '../components/ErrorBanner';
+import LoadingState from '../components/LoadingState';
+import MetricCards from '../components/MetricCards';
+import InsightsPanel from '../components/InsightsPanel';
+import DocumentSummaryCard from '../components/DocumentSummaryCard';
+import { usePredict } from '../api/hooks';
+import { useAssessment } from '../store/AssessmentContext';
+import { ScenarioPayload } from '../types';
+
+const initialScenario: ScenarioPayload = {
+ companyName: '',
+ industry: '',
+ narrative: '',
+ timeHorizon: 'Q3 FY25',
+ riskAppetite: 3,
+ revenue: { current: 2_500_000, projected: 3_200_000 },
+ expenses: { current: 1_400_000, projected: 1_550_000 },
+ pipeline: { weightedPipeline: 4_500_000, winRate: 32, avgDealSize: 45000 },
+ retention: { churnRate: 6, expansionRate: 12 },
+ kpis: [
+ { label: 'SQLs', current: 420, target: 520, unit: 'leads' },
+ { label: 'Customer NPS', current: 48, target: 60, unit: 'score' },
+ ],
+};
+
+const PredictiveIntelligence: React.FC = () => {
+ const navigate = useNavigate();
+ const { documents, setScenario, setPrediction, setMetrics, dataNotes } = useAssessment();
+ const [scenario, updateScenario] = useState(initialScenario);
+ const [error, setError] = useState();
+
+ const { mutateAsync, isPending, data } = usePredict();
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setError(undefined);
+ if (!scenario.companyName || !scenario.industry) {
+ setError('Company name and industry are required.');
+ return;
+ }
+ try {
+ const scenarioPayload = { ...scenario, dataNotes };
+ const response = await mutateAsync({
+ scenario: scenarioPayload,
+ documents,
+ comparison: {},
+ });
+ setScenario(scenarioPayload);
+ setPrediction(response);
+ setMetrics(response.metrics);
+ navigate('/');
+ } catch (err) {
+ setError((err as Error).message);
+ }
+ };
+
+ const metrics = useMemo(() => data?.metrics, [data]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Company fundamentals
+ updateScenario({ ...scenario, companyName: event.target.value })}
+ required
+ />
+ updateScenario({ ...scenario, industry: event.target.value })}
+ required
+ />
+ updateScenario({ ...scenario, timeHorizon: event.target.value })}
+ />
+ {
+ const value = event.target.value;
+ updateScenario({ ...scenario, runwayMonths: value ? Number(value) : undefined });
+ }}
+ />
+ updateScenario({ ...scenario, narrative: event.target.value })}
+ multiline
+ minRows={3}
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ revenue: { ...scenario.revenue, current: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ revenue: { ...scenario.revenue, projected: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ expenses: { ...scenario.expenses, current: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ expenses: { ...scenario.expenses, projected: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ Pipeline & retention snapshot
+
+
+
+ updateScenario({
+ ...scenario,
+ pipeline: { ...scenario.pipeline, weightedPipeline: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ pipeline: { ...scenario.pipeline, winRate: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ pipeline: { ...scenario.pipeline, avgDealSize: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ retention: { ...scenario.retention, churnRate: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+ updateScenario({
+ ...scenario,
+ retention: { ...scenario.retention, expansionRate: Number(event.target.value) },
+ })
+ }
+ />
+
+
+
+
+ Risk appetite
+
+
+ updateScenario({ ...scenario, riskAppetite: Array.isArray(value) ? value[0] : value })
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ KPI trackers
+
+
+ {scenario.kpis.map((kpi, index) => (
+
+
+ {
+ const next = [...scenario.kpis];
+ next[index] = { ...kpi, label: event.target.value };
+ updateScenario({ ...scenario, kpis: next });
+ }}
+ />
+
+
+ {
+ const next = [...scenario.kpis];
+ next[index] = { ...kpi, current: Number(event.target.value) };
+ updateScenario({ ...scenario, kpis: next });
+ }}
+ />
+
+
+ {
+ const next = [...scenario.kpis];
+ next[index] = { ...kpi, target: Number(event.target.value) };
+ updateScenario({ ...scenario, kpis: next });
+ }}
+ />
+
+
+ {
+ const next = [...scenario.kpis];
+ next[index] = { ...kpi, unit: event.target.value };
+ updateScenario({ ...scenario, kpis: next });
+ }}
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {isPending && }
+
+ {data && (
+
+ Preview results
+
+
+
+ {data.documentSummaries.map((summary) => (
+
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default PredictiveIntelligence;
diff --git a/crm-ui/src/pages/RiskCommand.tsx b/crm-ui/src/pages/RiskCommand.tsx
new file mode 100644
index 0000000..0d88fab
--- /dev/null
+++ b/crm-ui/src/pages/RiskCommand.tsx
@@ -0,0 +1,136 @@
+import {
+ Box,
+ Card,
+ CardContent,
+ LinearProgress,
+ Stack,
+ Typography,
+ Grid,
+ Paper,
+} from '@mui/material';
+import ShieldIcon from '@mui/icons-material/SecurityOutlined';
+import InsightsIcon from '@mui/icons-material/InsightsOutlined';
+import React from 'react';
+
+import Topbar from '../components/Topbar';
+import { useAssessment } from '../store/AssessmentContext';
+
+const labels = ['Very low', 'Low', 'Balanced', 'Growth', 'Aggressive'];
+
+const RiskCommand: React.FC = () => {
+ const { prediction, metrics, scenario } = useAssessment();
+
+ if (!prediction || !metrics) {
+ return (
+
+
+
+
+ No intelligence available yet
+
+
+ Head to the predictive workspace to generate your first forecast and populate this dashboard.
+
+
+
+ );
+ }
+
+ const appetiteIndex = Math.max(1, Math.min(5, Math.round(metrics.riskAppetite)));
+ const appetiteLabel = labels[appetiteIndex - 1];
+ const riskAnalysis = prediction.report.risk_analysis;
+ const aiAdvice = prediction.report.ai_advice;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Risk appetite
+
+ {scenario?.companyName || 'Portfolio'} • {appetiteLabel}
+
+
+
+ Score {metrics.riskAppetite.toFixed(1)} / 5
+
+
+
+
+
+
+
+
+
+ Exposure narrative
+
+
+ {riskAnalysis.exposures}
+
+
+ Mitigation plan
+
+
+ {riskAnalysis.mitigations}
+
+
+ Risk heatmap guidance
+
+
+ {riskAnalysis.heatmap}
+
+
+
+
+
+
+
+
+
+
+
+ AI advisory highlights
+
+ Strategic guidance
+
+
+ {aiAdvice.strategic_guidance}
+
+
+ Tactical steps
+
+
+ {aiAdvice.tactical_steps}
+
+
+ Automation ideas
+
+
+ {aiAdvice.automation_ideas}
+
+
+
+
+
+
+ );
+};
+
+export default RiskCommand;
diff --git a/crm-ui/src/store/AssessmentContext.tsx b/crm-ui/src/store/AssessmentContext.tsx
new file mode 100644
index 0000000..e8abec4
--- /dev/null
+++ b/crm-ui/src/store/AssessmentContext.tsx
@@ -0,0 +1,76 @@
+import React, { createContext, useContext, useMemo, useState } from 'react';
+
+import {
+ ComparisonResponse,
+ DocumentUpload,
+ MetricsBundle,
+ PredictionResponse,
+ ScenarioPayload,
+} from '../types';
+
+interface AssessmentState {
+ scenario?: ScenarioPayload;
+ prediction?: PredictionResponse;
+ metrics?: MetricsBundle;
+ comparison?: ComparisonResponse;
+ documents: DocumentUpload[];
+ dataNotes: string;
+ transcript: string;
+ setScenario: (scenario: ScenarioPayload) => void;
+ setPrediction: (prediction: PredictionResponse | undefined) => void;
+ setMetrics: (metrics: MetricsBundle | undefined) => void;
+ setDocuments: (documents: DocumentUpload[]) => void;
+ setDataNotes: (notes: string) => void;
+ appendTranscript: (entry: string) => void;
+ setComparison: (comparison: ComparisonResponse | undefined) => void;
+ reset: () => void;
+}
+
+const AssessmentContext = createContext(undefined);
+
+export const AssessmentProvider: React.FC = ({ children }) => {
+ const [scenario, setScenario] = useState();
+ const [prediction, setPredictionState] = useState();
+ const [metrics, setMetricsState] = useState();
+ const [comparison, setComparison] = useState();
+ const [documents, setDocuments] = useState([]);
+ const [dataNotes, setDataNotes] = useState('');
+ const [transcript, setTranscript] = useState('');
+
+ const value = useMemo(
+ () => ({
+ scenario,
+ prediction,
+ metrics,
+ comparison,
+ documents,
+ dataNotes,
+ transcript,
+ setScenario,
+ setPrediction: setPredictionState,
+ setMetrics: setMetricsState,
+ setDocuments,
+ setDataNotes,
+ setComparison,
+ appendTranscript: (entry: string) =>
+ setTranscript((prev) => (prev ? `${prev}\n${entry}` : entry)),
+ reset: () => {
+ setPredictionState(undefined);
+ setMetricsState(undefined);
+ setComparison(undefined);
+ setTranscript('');
+ },
+ }),
+ [scenario, prediction, metrics, comparison, documents, dataNotes, transcript],
+ );
+
+ return {children};
+};
+
+export const useAssessment = (): AssessmentState => {
+ const context = useContext(AssessmentContext);
+ if (!context) {
+ throw new Error('useAssessment must be used within an AssessmentProvider');
+ }
+ return context;
+};
diff --git a/crm-ui/src/theme.ts b/crm-ui/src/theme.ts
new file mode 100644
index 0000000..0a936f7
--- /dev/null
+++ b/crm-ui/src/theme.ts
@@ -0,0 +1,56 @@
+import { createTheme } from '@mui/material/styles';
+
+const theme = createTheme({
+ palette: {
+ mode: 'dark',
+ primary: {
+ main: '#00bcd4',
+ },
+ secondary: {
+ main: '#7c4dff',
+ },
+ background: {
+ default: '#0b1120',
+ paper: '#111c2f',
+ },
+ },
+ typography: {
+ fontFamily: '"Inter", "Segoe UI", sans-serif',
+ h4: {
+ fontWeight: 600,
+ },
+ h5: {
+ fontWeight: 600,
+ },
+ },
+ components: {
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ borderRadius: 16,
+ backgroundImage: 'linear-gradient(145deg, rgba(17,28,47,0.98), rgba(13,19,33,0.98))',
+ border: '1px solid rgba(255,255,255,0.05)',
+ },
+ },
+ },
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ borderRadius: 999,
+ textTransform: 'none',
+ fontWeight: 600,
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ borderRadius: 20,
+ backdropFilter: 'blur(12px)',
+ },
+ },
+ },
+ },
+});
+
+export default theme;
diff --git a/crm-ui/src/types.ts b/crm-ui/src/types.ts
new file mode 100644
index 0000000..2285798
--- /dev/null
+++ b/crm-ui/src/types.ts
@@ -0,0 +1,115 @@
+export interface ScenarioPayload {
+ companyName: string;
+ industry: string;
+ narrative: string;
+ dataNotes?: string;
+ timeHorizon: string;
+ riskAppetite: number;
+ runwayMonths?: number;
+ revenue: { current: number; projected: number };
+ expenses: { current: number; projected: number };
+ pipeline: {
+ weightedPipeline: number;
+ winRate: number;
+ avgDealSize: number;
+ };
+ retention: {
+ churnRate: number;
+ expansionRate: number;
+ };
+ kpis: Array<{ label: string; current: number; target: number; unit: string }>;
+}
+
+export interface DocumentUpload {
+ filename: string;
+ content: string;
+}
+
+export interface PredictionResponse {
+ report: CRMReport;
+ documentSummaries: Array;
+ metrics: MetricsBundle;
+}
+
+export interface CRMReport {
+ data_management: Section;
+ predictive_outlook: Section;
+ risk_analysis: Section;
+ ai_advice: Section;
+ crm_actions: Section;
+}
+
+export interface Section {
+ [key: string]: string;
+}
+
+export interface DocumentSummary {
+ filename: string;
+ format: string;
+ summary: string;
+ excerpt: string;
+ length: number;
+}
+
+export interface MetricsBundle {
+ growth: {
+ revenueDelta: Delta;
+ expenseDelta: Delta;
+ };
+ efficiency: {
+ operatingMargin: number;
+ runway?: number;
+ };
+ pipeline: {
+ weightedValue: number;
+ winRate: number;
+ avgDealSize: number;
+ };
+ retention: {
+ churnRate: number;
+ expansionRate: number;
+ netRevenueRetention: number;
+ };
+ kpis: Array<{
+ label: string;
+ current: number;
+ target: number;
+ unit: string;
+ progress: number;
+ }>;
+ riskAppetite: number;
+ horizon?: string;
+}
+
+export interface Delta {
+ current: number;
+ target: number;
+ delta: number;
+ direction: 'up' | 'down';
+ percentChange: number;
+}
+
+export interface ComparisonResponse {
+ analysis: ComparisonAnalysis;
+ diff: DiffSummary;
+ documents: DocumentSummary[];
+}
+
+export interface ComparisonAnalysis {
+ alignment: string;
+ contradictions: string;
+ integration_paths: string;
+ risk_flags: string;
+}
+
+export interface DiffSummary {
+ similarity: number;
+ changeCount: number;
+ changes: Array<{
+ type: string;
+ sourceRange: [number, number];
+ targetRange: [number, number];
+ sourceExcerpt: string;
+ targetExcerpt: string;
+ }>;
+}
diff --git a/crm-ui/tsconfig.json b/crm-ui/tsconfig.json
new file mode 100644
index 0000000..a4fceaf
--- /dev/null
+++ b/crm-ui/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/crm-ui/tsconfig.node.json b/crm-ui/tsconfig.node.json
new file mode 100644
index 0000000..16dfedc
--- /dev/null
+++ b/crm-ui/tsconfig.node.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/crm-ui/vite.config.ts b/crm-ui/vite.config.ts
new file mode 100644
index 0000000..c1885d3
--- /dev/null
+++ b/crm-ui/vite.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig, loadEnv } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+ return {
+ plugins: [react()],
+ server: {
+ port: 5173,
+ host: '0.0.0.0',
+ proxy: {
+ '/api': {
+ target: env.VITE_API_BASE_URL ?? 'http://localhost:5000/api',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, ''),
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ },
+ };
+});
diff --git a/install.bat b/install.bat
new file mode 100644
index 0000000..1dc0c75
--- /dev/null
+++ b/install.bat
@@ -0,0 +1,46 @@
+@echo off
+setlocal enabledelayedexpansion
+
+REM Determine which Python executable is available
+where python >nul 2>&1
+if %errorlevel%==0 (
+ set "PYTHON_EXEC=python"
+) else (
+ where py >nul 2>&1
+ if %errorlevel%==0 (
+ set "PYTHON_EXEC=py -3"
+ ) else (
+ echo Python 3 is required but was not found in PATH.
+ echo Please install Python 3 and try again.
+ exit /b 1
+ )
+)
+
+for /f "tokens=2 delims= " %%a in ('%PYTHON_EXEC% --version 2^>^&1') do (
+ set "PY_VERSION=%%a"
+ goto version_found
+)
+:version_found
+if not defined PY_VERSION (
+ echo Unable to determine Python version.
+ echo Ensure Python 3 is correctly installed and available on PATH.
+ exit /b 1
+)
+
+echo Detected Python !PY_VERSION!
+echo Running CRM intelligence backend dependency check...
+%PYTHON_EXEC% install.py
+if %errorlevel% neq 0 (
+ echo Installer encountered an error.
+ exit /b %errorlevel%
+)
+
+echo.
+echo Python installation check complete.
+echo Next steps:
+echo 1. Create a virtual environment if desired, then run: %PYTHON_EXEC% -m pip install -r requirements.txt
+echo 2. Install the React workspace dependencies:
+echo cd crm-ui ^&^& npm install
+echo 3. Start the API: %PYTHON_EXEC% app.py
+echo 4. In another terminal: cd crm-ui ^&^& npm run dev
+endlocal
diff --git a/install.py b/install.py
new file mode 100644
index 0000000..50b4132
--- /dev/null
+++ b/install.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+"""Simple dependency installer for the CRM intelligence stack."""
+
+from __future__ import annotations
+
+import importlib
+import subprocess
+import sys
+from pathlib import Path
+
+REQUIREMENTS = {
+ "flask": "flask",
+ "flask_cors": "flask-cors",
+ "dotenv": "python-dotenv",
+ "openai": "openai",
+}
+
+
+def ensure_packages() -> None:
+ missing = []
+ for module_name, package_name in REQUIREMENTS.items():
+ if not _is_installed(module_name):
+ missing.append(package_name)
+ if not missing:
+ print("All Python dependencies already satisfied.")
+ return
+
+ print("Installing missing packages:", ", ".join(missing))
+ subprocess.check_call([sys.executable, "-m", "pip", "install", *missing])
+
+
+def _is_installed(module_name: str) -> bool:
+ try:
+ importlib.import_module(module_name)
+ return True
+ except ModuleNotFoundError:
+ return False
+
+
+if __name__ == "__main__":
+ ensure_packages()
+ requirements_path = Path(__file__).with_name("requirements.txt")
+ print(f"For reproducible environments run: pip install -r {requirements_path}")
+ print("Run npm install inside crm-ui/ to set up the React workspace.")
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..fa1357b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+flask>=3.0.0
+flask-cors>=4.0.0
+python-dotenv>=1.0.0
+openai>=1.30.0