From cdf3a79838e2fbd6e07bc8ef35d71b9cfcc46e18 Mon Sep 17 00:00:00 2001 From: 112-stack Date: Fri, 14 Nov 2025 15:17:04 -0800 Subject: [PATCH] Rebuild CRM experience with Vite React and API endpoints --- .env.example | 4 + .gitignore | 6 + README.md | 103 ++++- app.py | 7 + app/__init__.py | 29 ++ app/chatgpt_client.py | 234 +++++++++++ app/routes.py | 286 ++++++++++++++ crm-ui/.env.example | 1 + crm-ui/index.html | 13 + crm-ui/package.json | 31 ++ crm-ui/src/App.tsx | 29 ++ crm-ui/src/api/client.ts | 10 + crm-ui/src/api/hooks.ts | 43 ++ crm-ui/src/components/ComparisonDiff.tsx | 48 +++ crm-ui/src/components/DocumentSummaryCard.tsx | 30 ++ crm-ui/src/components/ErrorBanner.tsx | 11 + crm-ui/src/components/InsightsPanel.tsx | 38 ++ crm-ui/src/components/LayoutShell.tsx | 16 + crm-ui/src/components/LoadingState.tsx | 13 + crm-ui/src/components/MetricCards.tsx | 146 +++++++ crm-ui/src/components/Sidebar.tsx | 66 ++++ crm-ui/src/components/Topbar.tsx | 55 +++ crm-ui/src/main.tsx | 30 ++ crm-ui/src/pages/AdvisorConsole.tsx | 119 ++++++ crm-ui/src/pages/ComparisonLab.tsx | 158 ++++++++ crm-ui/src/pages/Dashboard.tsx | 62 +++ crm-ui/src/pages/DataManagement.tsx | 158 ++++++++ crm-ui/src/pages/NotFound.tsx | 25 ++ crm-ui/src/pages/PredictiveIntelligence.tsx | 368 ++++++++++++++++++ crm-ui/src/pages/RiskCommand.tsx | 136 +++++++ crm-ui/src/store/AssessmentContext.tsx | 76 ++++ crm-ui/src/theme.ts | 56 +++ crm-ui/src/types.ts | 115 ++++++ crm-ui/tsconfig.json | 21 + crm-ui/tsconfig.node.json | 9 + crm-ui/vite.config.ts | 23 ++ install.bat | 46 +++ install.py | 44 +++ requirements.txt | 4 + 39 files changed, 2668 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app.py create mode 100644 app/__init__.py create mode 100644 app/chatgpt_client.py create mode 100644 app/routes.py create mode 100644 crm-ui/.env.example create mode 100644 crm-ui/index.html create mode 100644 crm-ui/package.json create mode 100644 crm-ui/src/App.tsx create mode 100644 crm-ui/src/api/client.ts create mode 100644 crm-ui/src/api/hooks.ts create mode 100644 crm-ui/src/components/ComparisonDiff.tsx create mode 100644 crm-ui/src/components/DocumentSummaryCard.tsx create mode 100644 crm-ui/src/components/ErrorBanner.tsx create mode 100644 crm-ui/src/components/InsightsPanel.tsx create mode 100644 crm-ui/src/components/LayoutShell.tsx create mode 100644 crm-ui/src/components/LoadingState.tsx create mode 100644 crm-ui/src/components/MetricCards.tsx create mode 100644 crm-ui/src/components/Sidebar.tsx create mode 100644 crm-ui/src/components/Topbar.tsx create mode 100644 crm-ui/src/main.tsx create mode 100644 crm-ui/src/pages/AdvisorConsole.tsx create mode 100644 crm-ui/src/pages/ComparisonLab.tsx create mode 100644 crm-ui/src/pages/Dashboard.tsx create mode 100644 crm-ui/src/pages/DataManagement.tsx create mode 100644 crm-ui/src/pages/NotFound.tsx create mode 100644 crm-ui/src/pages/PredictiveIntelligence.tsx create mode 100644 crm-ui/src/pages/RiskCommand.tsx create mode 100644 crm-ui/src/store/AssessmentContext.tsx create mode 100644 crm-ui/src/theme.ts create mode 100644 crm-ui/src/types.ts create mode 100644 crm-ui/tsconfig.json create mode 100644 crm-ui/tsconfig.node.json create mode 100644 crm-ui/vite.config.ts create mode 100644 install.bat create mode 100644 install.py create mode 100644 requirements.txt 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 && ( + + )} + +); + +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} + /> + + + + + + + + {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 ( + + + + + + + + + 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. + + + + ) : ( + <> + + + {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. + + + + {documents.length > 0 && ( + + )} + 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