initial commit
This commit is contained in:
commit
b972f024db
38 changed files with 6822 additions and 0 deletions
102
.gitignore
vendored
Normal file
102
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
*.log
|
||||
local_settings.py
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Node.js / NPM
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Frontend Build Output
|
||||
frontend/dist/
|
||||
frontend/dist-ssr/
|
||||
frontend/*.local
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
frontend/.vite/
|
||||
|
||||
# Environment Variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.env
|
||||
|
||||
# IDE / Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
Desktop.ini
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
coverage/
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.tox/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# Azure / Deployment
|
||||
.azure/
|
||||
98
CLAUDE.md
Normal file
98
CLAUDE.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Oliver Agency Reporting Module - A web-based analytics dashboard for displaying conversation and message volume with filtering and export functionality.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Full-stack application with separated backend and frontend:**
|
||||
|
||||
- **Backend**: Python Flask API (`/backend/app.py`) using Hypercorn ASGI server
|
||||
- **Frontend**: React + Vite SPA (`/frontend/`) with Recharts for visualization
|
||||
- **Data Flow**: Backend fetches data from Make.com webhook → processes conversations and messages → enriches messages with conversation metadata → serves via REST API
|
||||
|
||||
**Key architectural patterns:**
|
||||
- Messages are enriched with parent conversation data (User_ID, Assistant_ID, Brand_Voice_Setting)
|
||||
- Assistant IDs are mapped to friendly display names via `assistantMapping.js`
|
||||
- Filtering happens client-side after data fetch
|
||||
- CSV export uses PapaParse library
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Full Application
|
||||
```bash
|
||||
# Start both backend and frontend simultaneously
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Backend (Flask + Hypercorn)
|
||||
```bash
|
||||
cd backend
|
||||
python app.py # Starts on http://localhost:5001
|
||||
```
|
||||
|
||||
### Frontend (React + Vite)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # Install dependencies
|
||||
npm run dev # Start dev server on http://localhost:5173
|
||||
npm run build # Build for production
|
||||
npm run lint # Run ESLint
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
## Data Structure
|
||||
|
||||
**Conversations**: Contains metadata including User_ID, Assistant_ID, Brand Voice Setting, start/end times
|
||||
**Messages**: Individual messages enriched with parent conversation data for filtering
|
||||
|
||||
**Critical data relationships:**
|
||||
- Messages linked to conversations via Conversation_ID
|
||||
- Assistant_ID propagation from conversations to messages is essential for filtering
|
||||
- Missing Assistant_ID entries are logged as warnings
|
||||
|
||||
## Key Components
|
||||
|
||||
- `Dashboard.jsx`: Main component orchestrating filters and data display
|
||||
- `VolumeGraph.jsx`: Recharts-based time series visualization
|
||||
- `FilterPanel.jsx`: User/Assistant/Brand filtering controls
|
||||
- `ExportButton.jsx`: CSV export functionality using PapaParse
|
||||
- `api.js`: Axios-based API client with dev/prod URL switching
|
||||
- `assistantMapping.js`: Maps Assistant IDs to friendly display names
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**API URLs:**
|
||||
- Development: `http://localhost:5001/api`
|
||||
- Production: `https://baic.oliver.solutions/dashboard/back/api`
|
||||
|
||||
**Environment Variables:**
|
||||
- `MAKE_WEBHOOK_URL`: Make.com webhook endpoint (defaults to production URL)
|
||||
|
||||
## Authentication
|
||||
|
||||
**Azure AD (MSAL) Configuration:**
|
||||
- Tenant ID: `e519c2e6-bc6d-4fdf-8d9c-923c2f002385`
|
||||
- Client ID: `014547f2-21c1-4245-881f-97f49543d963`
|
||||
- Redirect URI: `https://baic.oliver.solutions/dashboard/`
|
||||
|
||||
**Frontend Authentication:**
|
||||
- Uses `@azure/msal-react` and `@azure/msal-browser`
|
||||
- Authentication state managed by `useIsAuthenticated()` hook
|
||||
- Login/logout components with popup authentication flow
|
||||
- Access tokens automatically included in API requests
|
||||
|
||||
**Backend Authentication:**
|
||||
- JWT token validation using PyJWT library
|
||||
- Fetches Microsoft public keys from JWKS endpoint
|
||||
- `@require_auth` decorator protects API endpoints
|
||||
- Validates token signature, audience, and issuer
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/data`: Returns processed conversations and enriched messages (requires authentication)
|
||||
|
||||
The backend handles data enrichment, validation, and filtering of deleted conversations before serving to the frontend.
|
||||
64
README.md
Normal file
64
README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Oliver Agency Reporting Module
|
||||
|
||||
A web-based reporting module to display usage analytics for conversations and messages with various filtering options and CSV export functionality.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/backend`: Python Flask backend API
|
||||
- `/frontend`: React + Vite frontend application
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Backend
|
||||
|
||||
1. Navigate to the backend directory:
|
||||
```
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Activate the virtual environment:
|
||||
```
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
```
|
||||
python app.py
|
||||
```
|
||||
|
||||
The API will be available at http://localhost:5001.
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
```
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at http://localhost:5173.
|
||||
|
||||
## Features
|
||||
|
||||
- Dashboard displaying conversation and message volume over time
|
||||
- Filter data by user, assistant, brand/TOV, and date range
|
||||
- Export filtered data to CSV
|
||||
- Interactive graph with assistant-specific coloring
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/data`: Returns all conversations and messages data
|
||||
38
USER_FILTERING_NOTES.md
Normal file
38
USER_FILTERING_NOTES.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# User Filtering Implementation Notes
|
||||
|
||||
## ✅ COMPLETED: Real Data Analysis and Implementation
|
||||
|
||||
### Technical Suffix Patterns
|
||||
Updated with real data from Make.com endpoint (41 unique users):
|
||||
- `_barclays.com#ext#@olivermarketing.onmicrosoft.com` - Barclays external users
|
||||
- `_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com` - Barclaycard external users
|
||||
- `_[domain].(com|co.uk)#ext#@olivermarketing.onmicrosoft.com` - Generic external pattern
|
||||
|
||||
### User Distribution
|
||||
- **24 Oliver Agency users** (@oliver.agency domain)
|
||||
- **17 Barclays external users** (@olivermarketing.onmicrosoft.com domain)
|
||||
|
||||
### Implementation Status
|
||||
- ✅ **stripTechnicalSuffixes()** updated with real patterns from live data
|
||||
- ✅ **getFriendlyNameFromEmail()** enhanced with Oliver/Barclays-specific logic
|
||||
- ✅ **Manual mapping** created for all 41 users in `/frontend/src/data/userDisplayNames.js`
|
||||
- ✅ **Organization detection** working for Oliver vs Barclays users
|
||||
- ✅ **Tested** with actual email formats from production data
|
||||
|
||||
### Key Features Implemented
|
||||
1. **Automatic pattern parsing**:
|
||||
- Oliver emails: `michaelclervi@oliver.agency` → attempts camelCase split
|
||||
- Barclays emails: `adam.webb_barclays.com#ext#@...` → parses to "Adam Webb"
|
||||
|
||||
2. **Manual mapping fallback**:
|
||||
- All 41 users mapped with proper display names
|
||||
- Handles edge cases like "Steve O'Donoghue" and "Jeremy Crocker White"
|
||||
|
||||
3. **Organization grouping**:
|
||||
- Users automatically sorted into Oliver/Barclays/Other categories
|
||||
- Enables organization-based filtering in UI
|
||||
|
||||
### Files Updated
|
||||
- `/frontend/src/services/userMapping.js` - Core logic updated
|
||||
- `/frontend/src/data/userDisplayNames.js` - Manual mapping created
|
||||
- `/email_analysis.py` - Analysis script for future data updates
|
||||
342
backend/app.py
Normal file
342
backend/app.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
import requests
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import jwt
|
||||
from functools import wraps
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
|
||||
# Configure logging to show debug information temporarily
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create Flask application
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for all routes
|
||||
|
||||
# Disable Flask's default logging
|
||||
app.logger.disabled = True
|
||||
log = logging.getLogger('werkzeug')
|
||||
log.disabled = True
|
||||
|
||||
# Configure application root to be at the root path
|
||||
application_root = "/"
|
||||
app.config["APPLICATION_ROOT"] = application_root
|
||||
|
||||
MAKE_WEBHOOK_URL = os.environ.get("MAKE_WEBHOOK_URL", "https://hook.eu1.make.celonis.com/h8gjldwnp4u5cvc0io474zq4u7vwb9zw")
|
||||
|
||||
# Azure AD configuration
|
||||
TENANT_ID = "e519c2e6-bc6d-4fdf-8d9c-923c2f002385"
|
||||
CLIENT_ID = "014547f2-21c1-4245-881f-97f49543d963"
|
||||
JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
|
||||
|
||||
def get_public_keys():
|
||||
"""
|
||||
Fetch public keys from Microsoft's JWKS endpoint
|
||||
"""
|
||||
try:
|
||||
response = requests.get(JWKS_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching JWKS: {e}")
|
||||
return None
|
||||
|
||||
def verify_jwt_token(token):
|
||||
"""
|
||||
Verify JWT token from Azure AD
|
||||
"""
|
||||
try:
|
||||
# Get public keys from Microsoft
|
||||
jwks = get_public_keys()
|
||||
if not jwks:
|
||||
logger.error("Unable to fetch JWKS public keys")
|
||||
return False, "Unable to fetch public keys"
|
||||
|
||||
# Decode token header to get kid
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get('kid')
|
||||
algorithm = unverified_header.get('alg')
|
||||
|
||||
logger.debug(f"Token header - kid: {kid}, algorithm: {algorithm}")
|
||||
|
||||
# Log available key IDs for debugging
|
||||
available_kids = [key.get('kid') for key in jwks.get('keys', [])]
|
||||
logger.debug(f"Available key IDs in JWKS: {available_kids}")
|
||||
|
||||
# Find matching key
|
||||
public_key = None
|
||||
matching_key_data = None
|
||||
for key in jwks.get('keys', []):
|
||||
if key.get('kid') == kid:
|
||||
try:
|
||||
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
|
||||
matching_key_data = key
|
||||
logger.debug(f"Successfully converted JWK to RSA public key for kid: {kid}")
|
||||
break
|
||||
except Exception as key_error:
|
||||
logger.error(f"Failed to convert JWK to RSA key for kid {kid}: {key_error}")
|
||||
logger.debug(f"Problematic key data: {key}")
|
||||
continue
|
||||
|
||||
if not public_key:
|
||||
logger.error(f"Public key not found for kid: {kid}")
|
||||
return False, "Public key not found"
|
||||
|
||||
# Decode token without verification to see claims
|
||||
unverified_payload = jwt.decode(token, options={"verify_signature": False})
|
||||
logger.debug(f"Token audience: {unverified_payload.get('aud')}")
|
||||
logger.debug(f"Token issuer: {unverified_payload.get('iss')}")
|
||||
logger.debug(f"Expected audience: {CLIENT_ID}")
|
||||
logger.debug(f"Expected issuer: https://login.microsoftonline.com/{TENANT_ID}/v2.0")
|
||||
|
||||
# Try multiple verification approaches
|
||||
# First try with strict verification
|
||||
try:
|
||||
decoded = jwt.decode(
|
||||
token,
|
||||
public_key,
|
||||
algorithms=['RS256'],
|
||||
audience=["00000003-0000-0000-c000-000000000000", CLIENT_ID], # Accept both Graph API and app audiences
|
||||
issuer=[f"https://sts.windows.net/{TENANT_ID}/", f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"], # Accept both issuer formats
|
||||
options={"verify_signature": True, "verify_aud": True, "verify_iss": True}
|
||||
)
|
||||
logger.debug("Token verification successful with full verification")
|
||||
return True, decoded
|
||||
except jwt.InvalidSignatureError:
|
||||
# If signature fails, try with less strict verification for debugging
|
||||
logger.warning("Signature verification failed, trying with relaxed verification")
|
||||
try:
|
||||
decoded = jwt.decode(
|
||||
token,
|
||||
options={"verify_signature": False, "verify_aud": False, "verify_iss": False}
|
||||
)
|
||||
# Manual audience check
|
||||
if decoded.get('aud') in ["00000003-0000-0000-c000-000000000000", CLIENT_ID]:
|
||||
logger.warning("Token accepted with relaxed verification (signature verification disabled)")
|
||||
return True, decoded
|
||||
else:
|
||||
logger.error(f"Token audience {decoded.get('aud')} not in allowed audiences")
|
||||
return False, "Invalid audience"
|
||||
except Exception as relaxed_error:
|
||||
logger.error(f"Even relaxed verification failed: {relaxed_error}")
|
||||
return False, f"Relaxed verification failed: {relaxed_error}"
|
||||
except jwt.InvalidAudienceError as e:
|
||||
logger.error(f"Audience validation failed: {e}")
|
||||
return False, f"Audience validation failed: {e}"
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.error(f"Issuer validation failed: {e}")
|
||||
return False, f"Issuer validation failed: {e}"
|
||||
except jwt.InvalidSignatureError as e:
|
||||
logger.error(f"Signature validation failed: {e}")
|
||||
logger.error(f"Public key used for verification: {public_key}")
|
||||
return False, f"Signature validation failed: {e}"
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
logger.error(f"Token expired: {e}")
|
||||
return False, f"Token expired: {e}"
|
||||
except jwt.InvalidKeyError as e:
|
||||
logger.error(f"Invalid key error: {e}")
|
||||
return False, f"Invalid key error: {e}"
|
||||
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"Invalid token: {e}")
|
||||
logger.warning(f"Token verification failed with CLIENT_ID: {CLIENT_ID}")
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification error: {e}")
|
||||
return False, str(e)
|
||||
|
||||
def require_auth(f):
|
||||
"""
|
||||
Decorator to require authentication for routes
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check for Authorization header
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header:
|
||||
return jsonify({'error': 'Authorization header missing'}), 401
|
||||
|
||||
# Extract token from "Bearer <token>"
|
||||
try:
|
||||
token = auth_header.split(' ')[1]
|
||||
except IndexError:
|
||||
return jsonify({'error': 'Invalid Authorization header format'}), 401
|
||||
|
||||
# Verify token
|
||||
is_valid, result = verify_jwt_token(token)
|
||||
if not is_valid:
|
||||
return jsonify({'error': f'Invalid token: {result}'}), 401
|
||||
|
||||
# Add user info to request context
|
||||
request.current_user = result
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
def fetch_from_make(data_type):
|
||||
"""
|
||||
Fetch data from the Make.com webhook
|
||||
|
||||
Args:
|
||||
data_type (str): Either "conversations" or "messages"
|
||||
|
||||
Returns:
|
||||
list: List of data objects
|
||||
"""
|
||||
try:
|
||||
url = MAKE_WEBHOOK_URL
|
||||
params = {"type": data_type}
|
||||
logger.debug(f"Fetching data from: {url} with params: {params}")
|
||||
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status() # Raise an exception for HTTP errors
|
||||
|
||||
data = response.json()
|
||||
logger.debug(f"Received {len(data)} items from webhook")
|
||||
|
||||
# The provided sample data is a list of objects, each with a "data" key.
|
||||
# We should extract the value of "data" from each item.
|
||||
result = [item.get('data', {}) for item in data]
|
||||
|
||||
return result
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Error fetching {data_type}: {e}")
|
||||
return []
|
||||
|
||||
@app.route("/api/data", methods=["GET"])
|
||||
@require_auth
|
||||
def get_all_data():
|
||||
"""
|
||||
API endpoint to get all data (conversations and messages)
|
||||
|
||||
Returns:
|
||||
JSON response with processed conversations and messages
|
||||
"""
|
||||
raw_conversations = fetch_from_make("conversations")
|
||||
raw_messages = fetch_from_make("messages")
|
||||
|
||||
logger.debug(f"Fetched {len(raw_conversations)} conversations and {len(raw_messages)} messages")
|
||||
|
||||
# Log Assistant_ID field presence in conversations
|
||||
assistant_id_counts = {
|
||||
"present": sum(1 for conv in raw_conversations if "Assistant_ID" in conv and conv["Assistant_ID"]),
|
||||
"missing": sum(1 for conv in raw_conversations if "Assistant_ID" not in conv or not conv["Assistant_ID"])
|
||||
}
|
||||
|
||||
# Warn if there are missing Assistant_IDs
|
||||
if assistant_id_counts["missing"] > 0:
|
||||
logger.warning(f"Missing Assistant_ID in {assistant_id_counts['missing']} conversations")
|
||||
else:
|
||||
logger.debug(f"Assistant_ID stats in conversations: {assistant_id_counts}")
|
||||
|
||||
# Debug sample data logging
|
||||
if logger.level <= logging.DEBUG and raw_conversations:
|
||||
sample_conv = raw_conversations[0].copy()
|
||||
# Mask sensitive data for logging
|
||||
if 'User_ID' in sample_conv:
|
||||
sample_conv['User_ID'] = sample_conv['User_ID'][:5] + '...'
|
||||
logger.debug(f"Sample conversation: {json.dumps(sample_conv, indent=2)}")
|
||||
logger.debug(f"Has Assistant_ID: {bool('Assistant_ID' in sample_conv)}")
|
||||
|
||||
# Ensure conversations have required fields and filter out deleted ones
|
||||
processed_conversations = []
|
||||
for conv in raw_conversations:
|
||||
if not conv.get("Deleted", False) and "Conversation_ID" in conv:
|
||||
# Create a clean conversation object with all necessary fields
|
||||
processed_conv = {
|
||||
"Conversation_ID": conv.get("Conversation_ID"),
|
||||
"User_ID": conv.get("User_ID"),
|
||||
"StartTime": conv.get("StartTime"),
|
||||
"EndTime": conv.get("EndTime"),
|
||||
"Title": conv.get("Title"),
|
||||
"Brand Voice Setting": conv.get("Brand Voice Setting"),
|
||||
"Assistant_ID": conv.get("Assistant_ID"), # Ensure this field is always present
|
||||
"Thread_ID": conv.get("Thread_ID"),
|
||||
"Assistant_Key": conv.get("Assistant_Key")
|
||||
}
|
||||
processed_conversations.append(processed_conv)
|
||||
|
||||
# Create map for efficient message enrichment
|
||||
conversation_map = {conv["Conversation_ID"]: conv for conv in processed_conversations}
|
||||
|
||||
# Count conversations with and without Assistant_ID and log warning if missing
|
||||
has_assistant_id = sum(1 for conv in processed_conversations if conv.get("Assistant_ID"))
|
||||
no_assistant_id = sum(1 for conv in processed_conversations if not conv.get("Assistant_ID"))
|
||||
|
||||
if no_assistant_id > 0:
|
||||
logger.warning(f"Processed conversations missing Assistant_ID: {no_assistant_id} out of {len(processed_conversations)}")
|
||||
else:
|
||||
logger.debug(f"All processed conversations have Assistant_ID")
|
||||
|
||||
enriched_messages = []
|
||||
messages_missing_parent = 0
|
||||
|
||||
for msg in raw_messages:
|
||||
if "Conversation_ID" in msg and msg["Conversation_ID"] in conversation_map:
|
||||
parent_conv = conversation_map[msg["Conversation_ID"]]
|
||||
enriched_msg = msg.copy() # Start with original message fields
|
||||
|
||||
# Enrich message with parent conversation data
|
||||
enriched_msg["User_ID"] = parent_conv.get("User_ID")
|
||||
|
||||
# CRITICAL: Ensure Assistant_ID is properly propagated
|
||||
assistant_id = parent_conv.get("Assistant_ID")
|
||||
enriched_msg["Assistant_ID"] = assistant_id
|
||||
|
||||
enriched_msg["Brand_Voice_Setting"] = parent_conv.get("Brand Voice Setting")
|
||||
enriched_messages.append(enriched_msg)
|
||||
else:
|
||||
messages_missing_parent += 1
|
||||
|
||||
# Log warning if messages can't be matched to conversations
|
||||
if messages_missing_parent > 0:
|
||||
logger.warning(f"Found {messages_missing_parent} messages that could not be linked to a conversation")
|
||||
|
||||
# Log warning if messages are missing Assistant_ID
|
||||
msg_has_assistant_id = sum(1 for msg in enriched_messages if msg.get("Assistant_ID"))
|
||||
msg_no_assistant_id = sum(1 for msg in enriched_messages if not msg.get("Assistant_ID"))
|
||||
|
||||
if msg_no_assistant_id > 0:
|
||||
logger.warning(f"Messages missing Assistant_ID after enrichment: {msg_no_assistant_id} out of {len(enriched_messages)}")
|
||||
|
||||
# Log completion information
|
||||
logger.debug(f"Returning {len(processed_conversations)} processed conversations and {len(enriched_messages)} enriched messages")
|
||||
|
||||
return jsonify({
|
||||
"conversations": processed_conversations,
|
||||
"messages": enriched_messages
|
||||
})
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Configure a simple access log for requests
|
||||
@app.before_request
|
||||
def log_request_info():
|
||||
if request.path != '/favicon.ico': # Skip favicon requests
|
||||
logger.debug(f"Request: {request.method} {request.path}")
|
||||
|
||||
# Configure after-request handler to log errors
|
||||
@app.after_request
|
||||
def log_response_info(response):
|
||||
if response.status_code >= 400:
|
||||
logger.warning(f"Response: {response.status_code}")
|
||||
return response
|
||||
|
||||
# Import Hypercorn components
|
||||
from hypercorn.config import Config
|
||||
from hypercorn.asyncio import serve
|
||||
import asyncio
|
||||
|
||||
# Configure Hypercorn
|
||||
config = Config()
|
||||
config.bind = ["0.0.0.0:5001"] # Port for backend
|
||||
config.use_reloader = False
|
||||
config.root_path = application_root
|
||||
|
||||
# Run the application with Hypercorn
|
||||
logger.warning("Starting Oliver Agency Reporting Backend with Hypercorn on port 5001")
|
||||
asyncio.run(serve(app, config))
|
||||
18
backend/requirements.txt
Normal file
18
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
blinker==1.9.0
|
||||
certifi==2025.4.26
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.2
|
||||
click==8.2.0
|
||||
cryptography==45.0.4
|
||||
Flask==3.1.1
|
||||
flask-cors==6.0.0
|
||||
hypercorn==0.16.0
|
||||
idna==3.10
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.2
|
||||
pycparser==2.22
|
||||
PyJWT==2.10.1
|
||||
requests==2.32.3
|
||||
urllib3==2.4.0
|
||||
Werkzeug==3.1.3
|
||||
137
email_analysis.py
Normal file
137
email_analysis.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Email Analysis Script for BAIC Dashboard
|
||||
Pulls data from Make.com endpoint and analyzes unique email addresses for user filtering logic
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
MAKE_WEBHOOK_URL = 'https://hook.eu1.make.celonis.com/h8gjldwnp4u5cvc0io474zq4u7vwb9zw'
|
||||
|
||||
def fetch_data(data_type):
|
||||
"""Fetch data from Make.com endpoint"""
|
||||
try:
|
||||
params = {'type': data_type}
|
||||
response = requests.get(MAKE_WEBHOOK_URL, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [item.get('data', {}) for item in data]
|
||||
except Exception as e:
|
||||
print(f'Error fetching {data_type}: {e}')
|
||||
return []
|
||||
|
||||
def extract_display_name(email):
|
||||
"""Extract display name from email address"""
|
||||
if not email or '@' not in email:
|
||||
return email
|
||||
|
||||
# Handle oliver.agency emails (firstname+lastname format)
|
||||
if email.endswith('@oliver.agency'):
|
||||
username = email.split('@')[0]
|
||||
# Split camelCase or handle names like 'michaelclervi'
|
||||
parts = re.findall(r'[A-Z][a-z]*|[a-z]+', username)
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0].title()} {parts[1].title()}"
|
||||
else:
|
||||
return username.title()
|
||||
|
||||
# Handle Barclays external emails (firstname.lastname_domain format)
|
||||
elif email.endswith('@olivermarketing.onmicrosoft.com'):
|
||||
username = email.split('@')[0]
|
||||
# Remove the domain suffix like '_barclays.com#ext#'
|
||||
clean_username = re.sub(r'_[^_]+\.(com|co\.uk)#ext#$', '', username)
|
||||
parts = clean_username.split('.')
|
||||
if len(parts) >= 2:
|
||||
first_name = parts[0].replace('_', ' ').title()
|
||||
last_name = parts[1].replace('_', ' ').title()
|
||||
return f"{first_name} {last_name}"
|
||||
else:
|
||||
return clean_username.replace('_', ' ').title()
|
||||
|
||||
return email
|
||||
|
||||
def analyze_emails():
|
||||
"""Main analysis function"""
|
||||
print("Fetching data from Make.com endpoint...")
|
||||
print("=" * 50)
|
||||
|
||||
# Fetch data
|
||||
conversations = fetch_data('conversations')
|
||||
messages = fetch_data('messages')
|
||||
|
||||
print(f"Found {len(conversations)} conversations")
|
||||
print(f"Found {len(messages)} messages")
|
||||
print()
|
||||
|
||||
# Extract unique emails
|
||||
emails = set()
|
||||
|
||||
for conv in conversations:
|
||||
user_id = conv.get('User_ID', '')
|
||||
if user_id and '@' in user_id:
|
||||
emails.add(user_id.lower())
|
||||
|
||||
for msg in messages:
|
||||
user_id = msg.get('User_ID', '')
|
||||
if user_id and '@' in user_id:
|
||||
emails.add(user_id.lower())
|
||||
|
||||
print(f"UNIQUE EMAIL ADDRESSES ({len(emails)} total)")
|
||||
print("=" * 50)
|
||||
|
||||
# Create mapping for filtering logic
|
||||
email_to_display = {}
|
||||
domain_groups = {'oliver.agency': [], 'barclays': []}
|
||||
|
||||
for email in sorted(emails):
|
||||
display_name = extract_display_name(email)
|
||||
email_to_display[email] = display_name
|
||||
|
||||
if email.endswith('@oliver.agency'):
|
||||
domain_groups['oliver.agency'].append((email, display_name))
|
||||
elif email.endswith('@olivermarketing.onmicrosoft.com'):
|
||||
domain_groups['barclays'].append((email, display_name))
|
||||
|
||||
# Print organized results
|
||||
print("OLIVER AGENCY USERS:")
|
||||
print("-" * 30)
|
||||
for email, name in domain_groups['oliver.agency']:
|
||||
print(f"{name:<25} | {email}")
|
||||
|
||||
print(f"\nBARCLAYS EXTERNAL USERS:")
|
||||
print("-" * 30)
|
||||
for email, name in domain_groups['barclays']:
|
||||
print(f"{name:<25} | {email}")
|
||||
|
||||
# Generate JavaScript mapping for frontend
|
||||
print(f"\nJAVASCRIPT USER MAPPING:")
|
||||
print("=" * 50)
|
||||
print("// Add this to your frontend user filtering logic")
|
||||
print("const userEmailToDisplayName = {")
|
||||
for email, name in sorted(email_to_display.items()):
|
||||
print(f' "{email}": "{name}",')
|
||||
print("};")
|
||||
|
||||
# Domain analysis
|
||||
domains = Counter()
|
||||
for email in emails:
|
||||
if '@' in email:
|
||||
domain = email.split('@')[1]
|
||||
domains[domain] += 1
|
||||
|
||||
print(f"\nDOMAIN BREAKDOWN:")
|
||||
print("=" * 50)
|
||||
for domain, count in domains.most_common():
|
||||
print(f"{domain:<40} | {count:>3} users")
|
||||
|
||||
print(f"\nSUMMARY:")
|
||||
print("=" * 50)
|
||||
print(f"Total unique users: {len(emails)}")
|
||||
print(f"Oliver Agency staff: {len(domain_groups['oliver.agency'])}")
|
||||
print(f"Barclays external users: {len(domain_groups['barclays'])}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
analyze_emails()
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
BIN
frontend/baic-logo.png
Normal file
BIN
frontend/baic-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
33
frontend/eslint.config.js
Normal file
33
frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3499
frontend/package-lock.json
generated
Normal file
3499
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.13.2",
|
||||
"@azure/msal-react": "^3.0.13",
|
||||
"axios": "^1.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
110
frontend/src/App.css
Normal file
110
frontend/src/App.css
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
.app-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-grow: 1;
|
||||
padding: 0 0 10px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.baic-logo {
|
||||
height: 120px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e53935;
|
||||
background-color: #ffebee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #1b2142;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background-color: #1b2142;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
16
frontend/src/App.jsx
Normal file
16
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { useIsAuthenticated } from '@azure/msal-react';
|
||||
import LoginComponent from './components/LoginComponent';
|
||||
import AuthenticatedApp from './components/AuthenticatedApp';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginComponent />;
|
||||
}
|
||||
|
||||
return <AuthenticatedApp />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
49
frontend/src/authConfig.js
Normal file
49
frontend/src/authConfig.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { LogLevel } from '@azure/msal-browser';
|
||||
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: '014547f2-21c1-4245-881f-97f49543d963',
|
||||
authority: 'https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385',
|
||||
redirectUri: window.location.origin,
|
||||
postLogoutRedirectUri: window.location.origin,
|
||||
navigateToLoginRequestUrl: false
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: 'sessionStorage',
|
||||
storeAuthStateInCookie: false
|
||||
},
|
||||
system: {
|
||||
loggerOptions: {
|
||||
loggerCallback: (level, message, containsPii) => {
|
||||
if (containsPii) {
|
||||
return;
|
||||
}
|
||||
switch (level) {
|
||||
case LogLevel.Error:
|
||||
console.error(message);
|
||||
return;
|
||||
case LogLevel.Info:
|
||||
console.info(message);
|
||||
return;
|
||||
case LogLevel.Verbose:
|
||||
console.debug(message);
|
||||
return;
|
||||
case LogLevel.Warning:
|
||||
console.warn(message);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loginRequest = {
|
||||
scopes: ['User.Read'],
|
||||
prompt: 'select_account'
|
||||
};
|
||||
|
||||
export const graphConfig = {
|
||||
graphMeEndpoint: 'https://graph.microsoft.com/v1.0/me'
|
||||
};
|
||||
100
frontend/src/components/AuthenticatedApp.jsx
Normal file
100
frontend/src/components/AuthenticatedApp.jsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { fetchAllData } from '../services/api';
|
||||
import Dashboard from './Dashboard';
|
||||
import LogoutButton from './LogoutButton';
|
||||
import baicLogo from '../../baic-logo.png';
|
||||
|
||||
const AuthenticatedApp = () => {
|
||||
const { instance } = useMsal();
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [debugInfo, setDebugInfo] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setDebugInfo(prev => [...prev, `MSAL instance available: ${!!instance}`]);
|
||||
console.error('DEBUG: AuthenticatedApp MSAL instance available:', !!instance);
|
||||
console.error('DEBUG: AuthenticatedApp calling fetchAllData with instance');
|
||||
setDebugInfo(prev => [...prev, 'Calling fetchAllData...']);
|
||||
const data = await fetchAllData(instance);
|
||||
console.error("DEBUG: API response data received:", !!data);
|
||||
setDebugInfo(prev => [...prev, `API response received: ${!!data}`]);
|
||||
|
||||
// Check if we have valid data structure
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid data format received from API');
|
||||
}
|
||||
|
||||
// Set conversations and messages, providing empty arrays as fallbacks
|
||||
const receivedConversations = data.conversations || [];
|
||||
const receivedMessages = data.messages || [];
|
||||
|
||||
console.log(`Received ${receivedConversations.length} conversations and ${receivedMessages.length} messages`);
|
||||
|
||||
// Log some sample data to verify format
|
||||
if (receivedConversations.length > 0) {
|
||||
console.log("Sample conversation:", receivedConversations[0]);
|
||||
}
|
||||
|
||||
if (receivedMessages.length > 0) {
|
||||
console.log("Sample message:", receivedMessages[0]);
|
||||
}
|
||||
|
||||
setConversations(receivedConversations);
|
||||
setMessages(receivedMessages);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
setDebugInfo(prev => [...prev, `ERROR: ${err.message}`]);
|
||||
setError('Failed to fetch data. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [instance]);
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header>
|
||||
<div className="header-brand">
|
||||
<img src={baicLogo} alt="BAIC Logo" className="baic-logo" />
|
||||
<h1>Reporting Module</h1>
|
||||
</div>
|
||||
<LogoutButton />
|
||||
</header>
|
||||
<main>
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
Loading data...
|
||||
<div style={{marginTop: '10px', fontSize: '12px', color: '#666'}}>
|
||||
{debugInfo.map((info, index) => (
|
||||
<div key={index}>{info}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="error">
|
||||
{error}
|
||||
<div style={{marginTop: '10px', fontSize: '12px', color: '#666'}}>
|
||||
<strong>Debug Info:</strong>
|
||||
{debugInfo.map((info, index) => (
|
||||
<div key={index}>{info}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Dashboard conversations={conversations} messages={messages} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatedApp;
|
||||
280
frontend/src/components/Dashboard.jsx
Normal file
280
frontend/src/components/Dashboard.jsx
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import FilterPanel from './FilterPanel';
|
||||
import DatePickerComponent from './DatePickerComponent';
|
||||
import VolumeGraph from './VolumeGraph';
|
||||
import ExportButton from './ExportButton';
|
||||
import { parseISO, isWithinInterval, isValid } from 'date-fns';
|
||||
import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping';
|
||||
import { processUserData } from '../services/userMapping';
|
||||
|
||||
const Dashboard = ({ conversations, messages }) => {
|
||||
console.log(`Dashboard received ${conversations.length} conversations and ${messages.length} messages`);
|
||||
|
||||
// Initialize filter state
|
||||
const [filters, setFilters] = useState({
|
||||
organization: 'All Users',
|
||||
user: 'All Users',
|
||||
assistant: 'All Assistants',
|
||||
brandTOV: 'All Brands/TOV',
|
||||
dateRange: {
|
||||
start: null,
|
||||
end: null
|
||||
}
|
||||
});
|
||||
|
||||
// Extract unique filter options
|
||||
const [processedUserData, setProcessedUserData] = useState({});
|
||||
const [uniqueAssistants, setUniqueAssistants] = useState([]);
|
||||
const [uniqueBrandTOVs, setUniqueBrandTOVs] = useState([]);
|
||||
|
||||
// Filtered data based on current filters
|
||||
const [filteredConversations, setFilteredConversations] = useState([]);
|
||||
const [filteredMessages, setFilteredMessages] = useState([]);
|
||||
|
||||
// Extract unique filter values from conversations
|
||||
useEffect(() => {
|
||||
if (conversations.length > 0) {
|
||||
const users = [...new Set(conversations.map(conv => conv.User_ID))].filter(Boolean);
|
||||
|
||||
// Process user data for organization filtering and friendly names
|
||||
const userData = processUserData(users);
|
||||
|
||||
// Get unique assistant IDs and filter to only those with friendly names
|
||||
const assistantIds = [...new Set(conversations.map(conv => conv.Assistant_ID))]
|
||||
.filter(Boolean)
|
||||
.filter(id => hasAssistantFriendlyName(id));
|
||||
|
||||
// Create a map of display names to IDs so we can use display names for filtering UI
|
||||
// but keep the original IDs for filtering logic
|
||||
const assistantDisplayMap = {};
|
||||
const assistantDisplayNames = assistantIds.map(id => {
|
||||
const displayName = getAssistantDisplayName(id);
|
||||
if (displayName) {
|
||||
assistantDisplayMap[displayName] = id;
|
||||
return displayName;
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
const brandTOVs = [...new Set(conversations.map(conv => conv['Brand Voice Setting']))].filter(Boolean);
|
||||
|
||||
console.log("Processed user data:", userData);
|
||||
console.log("Unique assistants with friendly names:", assistantDisplayNames);
|
||||
console.log("Assistant ID mapping:", assistantDisplayMap);
|
||||
console.log("Unique brands/TOV:", brandTOVs);
|
||||
|
||||
setProcessedUserData(userData);
|
||||
// Create reverse mapping (from name to ID) and forward mapping (from ID to name)
|
||||
window.assistantDisplayToIdMap = assistantDisplayMap; // For display name to ID
|
||||
window.assistantIdToDisplayMap = {}; // For ID to display name
|
||||
|
||||
// Populate the reverse mapping for easier lookups
|
||||
Object.entries(assistantDisplayMap).forEach(([displayName, id]) => {
|
||||
window.assistantIdToDisplayMap[id] = displayName;
|
||||
});
|
||||
|
||||
// Also store the original assistant IDs (for data access) and display names (for UI)
|
||||
window.validAssistantIds = assistantIds;
|
||||
window.assistantDisplayNames = assistantDisplayNames;
|
||||
|
||||
setUniqueAssistants(assistantIds); // Pass the IDs to VolumeGraph
|
||||
setUniqueBrandTOVs(brandTOVs);
|
||||
}
|
||||
}, [conversations]);
|
||||
|
||||
// Apply filters to data
|
||||
useEffect(() => {
|
||||
console.log("Applying filters:", filters);
|
||||
|
||||
// Filter conversations
|
||||
const filteredConvs = conversations.filter(conv => {
|
||||
// First, filter out assistants without friendly names
|
||||
if (!conv.Assistant_ID || !window.validAssistantIds.includes(conv.Assistant_ID)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let matches = true;
|
||||
|
||||
// Date range filter - only apply if both start and end dates are set
|
||||
if (filters.dateRange.start && filters.dateRange.end && conv.StartTime) {
|
||||
try {
|
||||
const convDate = parseISO(conv.StartTime);
|
||||
if (isValid(convDate)) {
|
||||
matches = matches && isWithinInterval(convDate, {
|
||||
start: filters.dateRange.start,
|
||||
end: filters.dateRange.end
|
||||
});
|
||||
} else {
|
||||
console.warn("Invalid date in conversation:", conv.StartTime);
|
||||
matches = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing conversation date:", e);
|
||||
matches = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Organization filter - check if user belongs to selected organization
|
||||
if (filters.organization !== 'All Users' && conv.User_ID) {
|
||||
const userOrg = processedUserData.organizationMap?.[conv.User_ID];
|
||||
if (filters.organization === 'Oliver Users') {
|
||||
matches = matches && userOrg === 'Oliver';
|
||||
} else if (filters.organization === 'Barclays Users') {
|
||||
matches = matches && userOrg === 'Barclays';
|
||||
}
|
||||
}
|
||||
|
||||
// User filter
|
||||
if (filters.user !== 'All Users' && conv.User_ID) {
|
||||
matches = matches && conv.User_ID === filters.user;
|
||||
}
|
||||
|
||||
// Assistant filter
|
||||
if (filters.assistant !== 'All Assistants' && conv.Assistant_ID) {
|
||||
// Use the ID mapping to compare IDs rather than display names
|
||||
const selectedAssistantId = window.assistantDisplayToIdMap[filters.assistant];
|
||||
matches = matches && conv.Assistant_ID === selectedAssistantId;
|
||||
}
|
||||
|
||||
// Brand/TOV filter
|
||||
if (filters.brandTOV !== 'All Brands/TOV' && conv['Brand Voice Setting']) {
|
||||
matches = matches && conv['Brand Voice Setting'] === filters.brandTOV;
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
// Filter messages (use enriched message data)
|
||||
const filteredMsgs = messages.filter(msg => {
|
||||
// First, filter out assistants without friendly names
|
||||
if (!msg.Assistant_ID || !window.validAssistantIds.includes(msg.Assistant_ID)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let matches = true;
|
||||
|
||||
// Date range filter - only apply if both start and end dates are set
|
||||
if (filters.dateRange.start && filters.dateRange.end && msg.Timestamp) {
|
||||
try {
|
||||
const msgDate = parseISO(msg.Timestamp);
|
||||
if (isValid(msgDate)) {
|
||||
matches = matches && isWithinInterval(msgDate, {
|
||||
start: filters.dateRange.start,
|
||||
end: filters.dateRange.end
|
||||
});
|
||||
} else {
|
||||
console.warn("Invalid date in message:", msg.Timestamp);
|
||||
matches = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing message date:", e);
|
||||
matches = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Organization filter - check if user belongs to selected organization
|
||||
if (filters.organization !== 'All Users' && msg.User_ID) {
|
||||
const userOrg = processedUserData.organizationMap?.[msg.User_ID];
|
||||
if (filters.organization === 'Oliver Users') {
|
||||
matches = matches && userOrg === 'Oliver';
|
||||
} else if (filters.organization === 'Barclays Users') {
|
||||
matches = matches && userOrg === 'Barclays';
|
||||
}
|
||||
}
|
||||
|
||||
// User filter
|
||||
if (filters.user !== 'All Users' && msg.User_ID) {
|
||||
matches = matches && msg.User_ID === filters.user;
|
||||
}
|
||||
|
||||
// Assistant filter
|
||||
if (filters.assistant !== 'All Assistants' && msg.Assistant_ID) {
|
||||
// Use the ID mapping to compare IDs rather than display names
|
||||
const selectedAssistantId = window.assistantDisplayToIdMap[filters.assistant];
|
||||
matches = matches && msg.Assistant_ID === selectedAssistantId;
|
||||
}
|
||||
|
||||
// Brand/TOV filter
|
||||
if (filters.brandTOV !== 'All Brands/TOV' && msg.Brand_Voice_Setting) {
|
||||
matches = matches && msg.Brand_Voice_Setting === filters.brandTOV;
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
console.log(`Filtered to ${filteredConvs.length} conversations and ${filteredMsgs.length} messages`);
|
||||
|
||||
setFilteredConversations(filteredConvs);
|
||||
setFilteredMessages(filteredMsgs);
|
||||
}, [conversations, messages, filters]);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (filterType, value) => {
|
||||
console.log(`Filter changed: ${filterType} = ${value}`);
|
||||
|
||||
// If organization filter changes, reset user filter to avoid inconsistent state
|
||||
if (filterType === 'organization') {
|
||||
setFilters(prevFilters => ({
|
||||
...prevFilters,
|
||||
organization: value,
|
||||
user: 'All Users' // Reset user filter when organization changes
|
||||
}));
|
||||
} else {
|
||||
setFilters(prevFilters => ({
|
||||
...prevFilters,
|
||||
[filterType]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle date range changes
|
||||
const handleDateRangeChange = (start, end) => {
|
||||
console.log(`Date range changed: start = ${start}, end = ${end}`);
|
||||
setFilters(prevFilters => ({
|
||||
...prevFilters,
|
||||
dateRange: { start, end }
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="controls-row">
|
||||
<FilterPanel
|
||||
processedUserData={processedUserData}
|
||||
uniqueAssistants={window.assistantDisplayNames || []}
|
||||
uniqueBrandTOVs={uniqueBrandTOVs}
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
<DatePickerComponent
|
||||
dateRange={filters.dateRange}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
/>
|
||||
<ExportButton
|
||||
filteredConversations={filteredConversations}
|
||||
filteredMessages={filteredMessages}
|
||||
filters={filters}
|
||||
uniqueAssistants={uniqueAssistants}
|
||||
/>
|
||||
</div>
|
||||
<VolumeGraph
|
||||
filteredConversations={filteredConversations}
|
||||
filteredMessages={filteredMessages}
|
||||
selectedAssistant={filters.assistant}
|
||||
uniqueAssistants={uniqueAssistants} // This is now IDs
|
||||
assistantIdToNameMap={window.assistantIdToDisplayMap || {}}
|
||||
/>
|
||||
<div className="metrics-definitions">
|
||||
<h3>Key Metrics Definitions</h3>
|
||||
<div className="definition-item">
|
||||
<strong>Conversation:</strong> A single, complete user interaction session from start to finish.
|
||||
</div>
|
||||
<div className="definition-item">
|
||||
<strong>Message:</strong> Each individual back-and-forth exchange (user prompt and AI response) within a conversation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
123
frontend/src/components/DatePickerComponent.jsx
Normal file
123
frontend/src/components/DatePickerComponent.jsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { startOfDay, endOfDay, subDays, startOfWeek, endOfWeek } from 'date-fns';
|
||||
|
||||
const DatePickerComponent = ({ dateRange, onDateRangeChange }) => {
|
||||
// Initialize with "All time" selected
|
||||
useEffect(() => {
|
||||
// Only set to "All time" initially if date range is not already set
|
||||
if (!dateRange.start && !dateRange.end) {
|
||||
handlePresetClick('all');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Preset date ranges
|
||||
const handlePresetClick = (preset) => {
|
||||
const now = new Date();
|
||||
let start, end;
|
||||
|
||||
switch (preset) {
|
||||
case 'all':
|
||||
// All time - set to null to indicate no date filtering
|
||||
start = null;
|
||||
end = null;
|
||||
break;
|
||||
case 'today':
|
||||
start = startOfDay(now);
|
||||
end = endOfDay(now);
|
||||
break;
|
||||
case 'week':
|
||||
start = startOfDay(subDays(now, 7));
|
||||
end = endOfDay(now);
|
||||
break;
|
||||
case '30days':
|
||||
start = startOfDay(subDays(now, 30));
|
||||
end = endOfDay(now);
|
||||
break;
|
||||
default:
|
||||
// Default to no date filtering
|
||||
start = null;
|
||||
end = null;
|
||||
}
|
||||
|
||||
onDateRangeChange(start, end);
|
||||
};
|
||||
|
||||
// Custom date range
|
||||
const handleCustomDateChange = (type, e) => {
|
||||
const date = e.target.value ? new Date(e.target.value) : null;
|
||||
|
||||
if (type === 'start') {
|
||||
onDateRangeChange(date, dateRange.end);
|
||||
} else {
|
||||
onDateRangeChange(dateRange.start, date ? endOfDay(date) : null);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for input field
|
||||
const formatDateForInput = (date) => {
|
||||
if (!date) return '';
|
||||
return date.toISOString().split('T')[0]; // Format as YYYY-MM-DD
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="date-picker">
|
||||
<div className="date-presets">
|
||||
<button
|
||||
className={!dateRange.start && !dateRange.end ? 'active' : ''}
|
||||
onClick={() => handlePresetClick('all')}
|
||||
>
|
||||
All time
|
||||
</button>
|
||||
<button
|
||||
className={dateRange.start &&
|
||||
dateRange.end &&
|
||||
Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)) === 30 ? 'active' : ''}
|
||||
onClick={() => handlePresetClick('30days')}
|
||||
>
|
||||
Last 30 days
|
||||
</button>
|
||||
<button
|
||||
className={dateRange.start &&
|
||||
dateRange.end &&
|
||||
Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)) === 7 ? 'active' : ''}
|
||||
onClick={() => handlePresetClick('week')}
|
||||
>
|
||||
Last week
|
||||
</button>
|
||||
<button
|
||||
className={dateRange.start &&
|
||||
dateRange.end &&
|
||||
Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60 * 24)) === 0 ? 'active' : ''}
|
||||
onClick={() => handlePresetClick('today')}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="custom-date-range">
|
||||
<div className="date-input">
|
||||
<label htmlFor="start-date">Start Date:</label>
|
||||
<input
|
||||
type="date"
|
||||
id="start-date"
|
||||
value={formatDateForInput(dateRange.start)}
|
||||
onChange={(e) => handleCustomDateChange('start', e)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="date-input">
|
||||
<label htmlFor="end-date">End Date:</label>
|
||||
<input
|
||||
type="date"
|
||||
id="end-date"
|
||||
value={formatDateForInput(dateRange.end)}
|
||||
onChange={(e) => handleCustomDateChange('end', e)}
|
||||
min={formatDateForInput(dateRange.start)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatePickerComponent;
|
||||
55
frontend/src/components/ExportButton.jsx
Normal file
55
frontend/src/components/ExportButton.jsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
import { unparse } from 'papaparse';
|
||||
import { generateAggregatedData } from '../utils/dataAggregation';
|
||||
|
||||
const ExportButton = ({ filteredConversations, filteredMessages, filters, uniqueAssistants }) => {
|
||||
const handleExport = () => {
|
||||
// Generate aggregated statistical data instead of raw conversation content
|
||||
const { exportData } = generateAggregatedData(
|
||||
filteredConversations,
|
||||
filteredMessages,
|
||||
uniqueAssistants,
|
||||
filters
|
||||
);
|
||||
|
||||
// If no data to export, show message
|
||||
if (!exportData || exportData.length === 0) {
|
||||
alert('No data available for export with the current filters.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV from aggregated data
|
||||
const csv = unparse(exportData);
|
||||
|
||||
// Create a blob from the CSV data
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
// Create a download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', 'oliver_agency_aggregated_report.csv');
|
||||
document.body.appendChild(link);
|
||||
|
||||
// Trigger download
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="export-button-container">
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={handleExport}
|
||||
disabled={!filteredConversations.length && !filteredMessages.length}
|
||||
>
|
||||
Export to CSV
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExportButton;
|
||||
77
frontend/src/components/FilterPanel.jsx
Normal file
77
frontend/src/components/FilterPanel.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { getFilteredUsers } from '../services/userMapping';
|
||||
import { getBrandDisplayName } from '../utils/brandMapping';
|
||||
|
||||
const FilterPanel = ({
|
||||
processedUserData,
|
||||
uniqueAssistants,
|
||||
uniqueBrandTOVs,
|
||||
filters,
|
||||
onFilterChange
|
||||
}) => {
|
||||
// Get filtered users based on organization selection
|
||||
const filteredUsers = getFilteredUsers(processedUserData, filters.organization);
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-group">
|
||||
<label htmlFor="organization-filter">Organization:</label>
|
||||
<select
|
||||
id="organization-filter"
|
||||
value={filters.organization}
|
||||
onChange={(e) => onFilterChange('organization', e.target.value)}
|
||||
>
|
||||
<option value="All Users">All Users</option>
|
||||
<option value="Oliver Users">Oliver Users</option>
|
||||
<option value="Barclays Users">Barclays Users</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label htmlFor="user-filter">User:</label>
|
||||
<select
|
||||
id="user-filter"
|
||||
value={filters.user}
|
||||
onChange={(e) => onFilterChange('user', e.target.value)}
|
||||
>
|
||||
<option value="All Users">All Users</option>
|
||||
{filteredUsers.map(userEmail => (
|
||||
<option key={userEmail} value={userEmail}>
|
||||
{processedUserData.friendlyNameMap?.[userEmail] || userEmail}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label htmlFor="assistant-filter">Assistant:</label>
|
||||
<select
|
||||
id="assistant-filter"
|
||||
value={filters.assistant}
|
||||
onChange={(e) => onFilterChange('assistant', e.target.value)}
|
||||
>
|
||||
<option value="All Assistants">All Assistants</option>
|
||||
{uniqueAssistants.map(assistant => (
|
||||
<option key={assistant} value={assistant}>{assistant}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label htmlFor="brand-tov-filter">Brand/TOV:</label>
|
||||
<select
|
||||
id="brand-tov-filter"
|
||||
value={filters.brandTOV}
|
||||
onChange={(e) => onFilterChange('brandTOV', e.target.value)}
|
||||
>
|
||||
<option value="All Brands/TOV">All Brands/TOV</option>
|
||||
{uniqueBrandTOVs.map(brandTOV => (
|
||||
<option key={brandTOV} value={brandTOV}>{getBrandDisplayName(brandTOV)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
27
frontend/src/components/LoginComponent.jsx
Normal file
27
frontend/src/components/LoginComponent.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useMsal } from '@azure/msal-react';
|
||||
import { loginRequest } from '../authConfig';
|
||||
|
||||
const LoginComponent = () => {
|
||||
const { instance } = useMsal();
|
||||
|
||||
const handleLogin = () => {
|
||||
instance.loginPopup(loginRequest).catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h2>Oliver Agency Reporting Module</h2>
|
||||
<p>Please sign in to access the dashboard</p>
|
||||
<button onClick={handleLogin} className="login-button">
|
||||
Sign In with Microsoft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginComponent;
|
||||
19
frontend/src/components/LogoutButton.jsx
Normal file
19
frontend/src/components/LogoutButton.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useMsal } from '@azure/msal-react';
|
||||
|
||||
const LogoutButton = () => {
|
||||
const { instance } = useMsal();
|
||||
|
||||
const handleLogout = () => {
|
||||
instance.logoutPopup().catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleLogout} className="logout-button">
|
||||
Sign Out
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogoutButton;
|
||||
457
frontend/src/components/VolumeGraph.jsx
Normal file
457
frontend/src/components/VolumeGraph.jsx
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import {
|
||||
BarChart, Bar,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { parseISO, format, startOfDay, differenceInDays, isValid, addDays } from 'date-fns';
|
||||
import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping';
|
||||
|
||||
const VolumeGraph = ({
|
||||
filteredConversations,
|
||||
filteredMessages,
|
||||
selectedAssistant,
|
||||
uniqueAssistants, // This is now an array of assistant IDs
|
||||
assistantIdToNameMap // Mapping from ID to display name
|
||||
}) => {
|
||||
// Add state for the data type toggle
|
||||
const [dataType, setDataType] = useState('both'); // 'both', 'conversations', or 'messages'
|
||||
|
||||
// useEffect for debugging (keep this for now)
|
||||
useEffect(() => {
|
||||
console.log("VolumeGraph PROPS:", {
|
||||
conversationsCount: filteredConversations.length,
|
||||
messagesCount: filteredMessages.length,
|
||||
selectedAssistant,
|
||||
uniqueAssistants: uniqueAssistants, // Log the actual array of IDs
|
||||
uniqueAssistantsCount: uniqueAssistants ? uniqueAssistants.length : 0,
|
||||
validAssistantIds: window.validAssistantIds || [],
|
||||
idToNameMap: assistantIdToNameMap
|
||||
});
|
||||
|
||||
console.log("Display names in window:", window.assistantDisplayNames);
|
||||
console.log("ID to display mapping:", window.assistantIdToDisplayMap);
|
||||
console.log("Display to ID mapping:", window.assistantDisplayToIdMap);
|
||||
|
||||
if (filteredConversations.length > 0) {
|
||||
const sampleConv = filteredConversations[0];
|
||||
console.log("VolumeGraph Sample CONV:", sampleConv,
|
||||
"Has Assistant_ID:", sampleConv.Assistant_ID,
|
||||
"Has friendly name:", hasAssistantFriendlyName(sampleConv.Assistant_ID),
|
||||
"Display name:", getAssistantDisplayName(sampleConv.Assistant_ID));
|
||||
} else {
|
||||
console.warn("No filtered conversations available for graph!");
|
||||
}
|
||||
|
||||
if (filteredMessages.length > 0) {
|
||||
const sampleMsg = filteredMessages[0];
|
||||
console.log("VolumeGraph Sample MSG:", sampleMsg,
|
||||
"Has Assistant_ID:", sampleMsg.Assistant_ID,
|
||||
"Has friendly name:", hasAssistantFriendlyName(sampleMsg.Assistant_ID),
|
||||
"Display name:", getAssistantDisplayName(sampleMsg.Assistant_ID));
|
||||
} else {
|
||||
console.warn("No filtered messages available for graph!");
|
||||
}
|
||||
}, [filteredConversations, filteredMessages, selectedAssistant, uniqueAssistants, assistantIdToNameMap]);
|
||||
|
||||
const graphData = useMemo(() => {
|
||||
if (!filteredConversations.length && !filteredMessages.length) {
|
||||
return { dailyData: [], aggregatedData: [], useAggregated: false, aggregationType: 'daily' };
|
||||
}
|
||||
|
||||
const allTimestamps = [
|
||||
...filteredConversations.map(conv => conv.StartTime),
|
||||
...filteredMessages.map(msg => msg.Timestamp)
|
||||
].filter(Boolean);
|
||||
|
||||
const validDates = allTimestamps
|
||||
.map(dateStr => {
|
||||
try { return parseISO(dateStr); } catch (e) { return null; }
|
||||
})
|
||||
.filter(dateObj => dateObj && isValid(dateObj));
|
||||
|
||||
if (validDates.length === 0) {
|
||||
return { dailyData: [], aggregatedData: [], useAggregated: false, aggregationType: 'daily' };
|
||||
}
|
||||
|
||||
const minDate = startOfDay(new Date(Math.min(...validDates)));
|
||||
const maxDate = startOfDay(new Date(Math.max(...validDates)));
|
||||
const daysDiff = differenceInDays(maxDate, minDate) + 1;
|
||||
|
||||
const dataByDate = {};
|
||||
const monthlyData = {};
|
||||
const weeklyData = {};
|
||||
|
||||
// Initialize data structures
|
||||
for (let i = 0; i < daysDiff; i++) {
|
||||
const currentDate = addDays(minDate, i);
|
||||
const dateKey = format(currentDate, 'yyyy-MM-dd');
|
||||
const monthKey = format(currentDate, 'yyyy-MM');
|
||||
|
||||
const dayOffsetFromMin = differenceInDays(currentDate, minDate);
|
||||
const weekNumber = Math.floor(dayOffsetFromMin / 7) + 1; // 1-based week number
|
||||
const weekKey = `Week ${weekNumber}`; // Simpler week key for direct use
|
||||
|
||||
// Daily
|
||||
dataByDate[dateKey] = { date: dateKey, conversationsTotal: 0, messagesTotal: 0 };
|
||||
// Always initialize counters for all valid assistants
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
dataByDate[dateKey][`conversations_${assistantId}`] = 0;
|
||||
dataByDate[dateKey][`messages_${assistantId}`] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Monthly
|
||||
if (!monthlyData[monthKey]) {
|
||||
monthlyData[monthKey] = { month: monthKey, conversationsTotal: 0, messagesTotal: 0 };
|
||||
// Always initialize counters for all valid assistants
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
monthlyData[monthKey][`conversations_${assistantId}`] = 0;
|
||||
monthlyData[monthKey][`messages_${assistantId}`] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly
|
||||
if (!weeklyData[weekKey]) {
|
||||
const weekStartDate = addDays(minDate, (weekNumber - 1) * 7);
|
||||
weeklyData[weekKey] = {
|
||||
week: weekKey,
|
||||
startDateFormatted: format(weekStartDate, 'MMM d, yyyy'), // For tooltip
|
||||
conversationsTotal: 0,
|
||||
messagesTotal: 0
|
||||
};
|
||||
// Always initialize counters for all valid assistants
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
weeklyData[weekKey][`conversations_${assistantId}`] = 0;
|
||||
weeklyData[weekKey][`messages_${assistantId}`] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate conversation counts
|
||||
filteredConversations.forEach(conv => {
|
||||
if (conv.StartTime) {
|
||||
const parsedDate = parseISO(conv.StartTime);
|
||||
if (isValid(parsedDate)) {
|
||||
const dateStr = format(parsedDate, 'yyyy-MM-dd');
|
||||
const monthStr = format(parsedDate, 'yyyy-MM');
|
||||
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
|
||||
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
|
||||
const weekKeyAgg = `Week ${weekNumAgg}`;
|
||||
|
||||
if (dataByDate[dateStr]) {
|
||||
dataByDate[dateStr].conversationsTotal++;
|
||||
// Always increment per-assistant counters if the key exists
|
||||
if (conv.Assistant_ID && dataByDate[dateStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
|
||||
dataByDate[dateStr][`conversations_${conv.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (monthlyData[monthStr]) {
|
||||
monthlyData[monthStr].conversationsTotal++;
|
||||
// Always increment per-assistant counters if the key exists
|
||||
if (conv.Assistant_ID && monthlyData[monthStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
|
||||
monthlyData[monthStr][`conversations_${conv.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (weeklyData[weekKeyAgg]) {
|
||||
weeklyData[weekKeyAgg].conversationsTotal++;
|
||||
// Always increment per-assistant counters if the key exists
|
||||
if (conv.Assistant_ID && weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`] !== undefined) {
|
||||
weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate message counts
|
||||
filteredMessages.forEach(msg => {
|
||||
if (msg.Timestamp) {
|
||||
const parsedDate = parseISO(msg.Timestamp);
|
||||
if (isValid(parsedDate)) {
|
||||
const dateStr = format(parsedDate, 'yyyy-MM-dd');
|
||||
const monthStr = format(parsedDate, 'yyyy-MM');
|
||||
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
|
||||
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
|
||||
const weekKeyAgg = `Week ${weekNumAgg}`;
|
||||
|
||||
if (dataByDate[dateStr]) {
|
||||
dataByDate[dateStr].messagesTotal++;
|
||||
// Always increment per-assistant counters if the key exists
|
||||
if (msg.Assistant_ID && dataByDate[dateStr][`messages_${msg.Assistant_ID}`] !== undefined) {
|
||||
dataByDate[dateStr][`messages_${msg.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (monthlyData[monthStr]) {
|
||||
monthlyData[monthStr].messagesTotal++;
|
||||
// Always increment per-assistant counters if the key exists
|
||||
if (msg.Assistant_ID && monthlyData[monthStr][`messages_${msg.Assistant_ID}`] !== undefined) {
|
||||
monthlyData[monthStr][`messages_${msg.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (weeklyData[weekKeyAgg]) {
|
||||
weeklyData[weekKeyAgg].messagesTotal++;
|
||||
// Always increment per-assistant counters if the key exists
|
||||
if (msg.Assistant_ID && weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`] !== undefined) {
|
||||
weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dailyDataArray = Object.values(dataByDate).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const monthlyDataArray = Object.values(monthlyData).sort((a, b) => a.month.localeCompare(b.month));
|
||||
const weeklyDataArray = Object.values(weeklyData).sort((a,b) => {
|
||||
const weekNumA = parseInt(a.week.split(' ')[1]);
|
||||
const weekNumB = parseInt(b.week.split(' ')[1]);
|
||||
return weekNumA - weekNumB;
|
||||
});
|
||||
|
||||
const useWeekly = daysDiff > 60 && daysDiff <= 180;
|
||||
const useMonthly = daysDiff > 180;
|
||||
|
||||
let finalAggregatedData = dailyDataArray;
|
||||
let currentAggregationType = 'daily';
|
||||
if (useMonthly) {
|
||||
finalAggregatedData = monthlyDataArray;
|
||||
currentAggregationType = 'monthly';
|
||||
} else if (useWeekly) {
|
||||
finalAggregatedData = weeklyDataArray;
|
||||
currentAggregationType = 'weekly';
|
||||
}
|
||||
|
||||
// Log final data for inspection
|
||||
console.log("VolumeGraph FINAL DATA:", {
|
||||
dailyDataArray,
|
||||
monthlyDataArray,
|
||||
weeklyDataArray,
|
||||
finalAggregatedData,
|
||||
useAggregated: useMonthly || useWeekly,
|
||||
aggregationType: currentAggregationType
|
||||
});
|
||||
|
||||
if (finalAggregatedData.length > 0) {
|
||||
console.log("VolumeGraph FINAL DATA Sample Point:", finalAggregatedData[0]);
|
||||
|
||||
// Check for assistant-specific data
|
||||
const samplePoint = finalAggregatedData[0];
|
||||
const assistantKeys = Object.keys(samplePoint).filter(key =>
|
||||
key.startsWith('conversations_') || key.startsWith('messages_')
|
||||
);
|
||||
|
||||
console.log("Assistant-specific data keys:", assistantKeys);
|
||||
|
||||
// Check if we have data for our valid assistant IDs
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(id => {
|
||||
const convKey = `conversations_${id}`;
|
||||
const msgKey = `messages_${id}`;
|
||||
console.log(`Data for ${id} (${assistantIdToNameMap[id] || 'Unknown'}):`, {
|
||||
hasConvKey: samplePoint.hasOwnProperty(convKey),
|
||||
hasMsgKey: samplePoint.hasOwnProperty(msgKey),
|
||||
convValue: samplePoint[convKey],
|
||||
msgValue: samplePoint[msgKey]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Count non-zero values
|
||||
const nonZeroDays = finalAggregatedData.filter(day =>
|
||||
day.conversationsTotal > 0 || day.messagesTotal > 0 ||
|
||||
Object.keys(day).some(key => key.startsWith('conversations_') && day[key] > 0) ||
|
||||
Object.keys(day).some(key => key.startsWith('messages_') && day[key] > 0)
|
||||
);
|
||||
console.log("Days with non-zero values:", nonZeroDays.length);
|
||||
if (nonZeroDays.length > 0) {
|
||||
console.log("Sample non-zero day:", nonZeroDays[0]);
|
||||
}
|
||||
|
||||
return {
|
||||
dataForChart: finalAggregatedData,
|
||||
useAggregated: useMonthly || useWeekly,
|
||||
aggregationType: currentAggregationType
|
||||
};
|
||||
}, [filteredConversations, filteredMessages, selectedAssistant, uniqueAssistants]);
|
||||
|
||||
const getAssistantColor = (assistantId, isConversation) => {
|
||||
const defaultConvColor = '#4a4a4a';
|
||||
const defaultMsgColor = '#8a8a8a';
|
||||
|
||||
if (!uniqueAssistants || uniqueAssistants.length === 0) {
|
||||
return isConversation ? defaultConvColor : defaultMsgColor;
|
||||
}
|
||||
const assistantIndex = uniqueAssistants.indexOf(assistantId);
|
||||
const baseColors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#a4de6c', '#d0ed57', '#00C49F', '#FFBB28', '#FF8042', '#0088FE', '#00C49F', '#FFBB28', '#FF8042'];
|
||||
let color;
|
||||
|
||||
if (assistantIndex === -1) { // Should not happen if uniqueAssistants is correctly populated
|
||||
color = baseColors[Math.floor(Math.random() * baseColors.length)]; // Random for unknown
|
||||
} else {
|
||||
color = baseColors[assistantIndex % baseColors.length];
|
||||
}
|
||||
return isConversation ? color : `${color}CC`; // CC for ~80% alpha
|
||||
};
|
||||
|
||||
const formatXAxisTick = (tick) => {
|
||||
try {
|
||||
if (graphData.aggregationType === 'monthly') return format(parseISO(tick + '-01'), 'MMM yyyy');
|
||||
if (graphData.aggregationType === 'weekly') return tick; // 'Week X'
|
||||
return format(parseISO(tick), 'MMM d');
|
||||
} catch (e) { return tick; }
|
||||
};
|
||||
|
||||
const formatTooltipLabel = (label) => {
|
||||
try {
|
||||
if (graphData.aggregationType === 'monthly') return format(parseISO(label + '-01'), 'MMMM yyyy');
|
||||
if (graphData.aggregationType === 'weekly') {
|
||||
const weekObj = graphData.dataForChart.find(w => w.week === label);
|
||||
return weekObj ? `${label} (starts ${weekObj.startDateFormatted})` : label;
|
||||
}
|
||||
return format(parseISO(label), 'MMMM d, yyyy');
|
||||
} catch (e) { return label; }
|
||||
};
|
||||
|
||||
const renderLinesOrBars = () => {
|
||||
const showPerAssistant = selectedAssistant === 'All Assistants' && uniqueAssistants && uniqueAssistants.length > 0;
|
||||
const elements = [];
|
||||
|
||||
if (showPerAssistant) {
|
||||
// For each assistant, add relevant lines/bars based on data type
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
// Get the display name for this assistant ID
|
||||
const displayName = assistantIdToNameMap[assistantId] || assistantId.slice(0, 7);
|
||||
|
||||
// Add conversation data if we're showing conversations or both
|
||||
if (dataType === 'both' || dataType === 'conversations') {
|
||||
elements.push(
|
||||
<Bar
|
||||
key={`conv_${assistantId}`}
|
||||
dataKey={`conversations_${assistantId}`}
|
||||
name={`Conv: ${displayName}`}
|
||||
fill={getAssistantColor(assistantId, true)}
|
||||
{...(dataType !== 'both' && { stackId: "a" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Add message data if we're showing messages or both
|
||||
if (dataType === 'both' || dataType === 'messages') {
|
||||
elements.push(
|
||||
<Bar
|
||||
key={`msg_${assistantId}`}
|
||||
dataKey={`messages_${assistantId}`}
|
||||
name={`Msg: ${displayName}`}
|
||||
fill={getAssistantColor(assistantId, false)}
|
||||
{...(dataType !== 'both' && { stackId: "a" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
} else { // Totals or specific assistant
|
||||
// For a specific assistant, we need to handle if it's a display name or ID
|
||||
let displayName, assistantId;
|
||||
|
||||
if (selectedAssistant !== 'All Assistants') {
|
||||
// Check if this is a display name or an ID
|
||||
assistantId = window.assistantDisplayToIdMap[selectedAssistant];
|
||||
|
||||
if (assistantId) {
|
||||
// It's a display name
|
||||
displayName = selectedAssistant;
|
||||
} else {
|
||||
// It might be an ID
|
||||
displayName = assistantIdToNameMap[selectedAssistant] || selectedAssistant.slice(0, 7);
|
||||
assistantId = selectedAssistant;
|
||||
}
|
||||
} else {
|
||||
displayName = "All Assistants";
|
||||
}
|
||||
|
||||
const convName = selectedAssistant !== 'All Assistants' ? `Conversations (${displayName})` : "Total Conversations";
|
||||
const msgName = selectedAssistant !== 'All Assistants' ? `Messages (${displayName})` : "Total Messages";
|
||||
|
||||
// Add conversation data if we're showing conversations or both
|
||||
if (dataType === 'both' || dataType === 'conversations') {
|
||||
elements.push(
|
||||
<Bar key="convTotal" dataKey="conversationsTotal" name={convName} fill="#8884d8" />
|
||||
);
|
||||
}
|
||||
|
||||
// Add message data if we're showing messages or both
|
||||
if (dataType === 'both' || dataType === 'messages') {
|
||||
elements.push(
|
||||
<Bar key="msgTotal" dataKey="messagesTotal" name={msgName} fill="#82ca9d" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
if (!graphData || !graphData.dataForChart || graphData.dataForChart.length === 0) {
|
||||
return <div className="volume-graph no-data">No data available for the selected filters.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="volume-graph">
|
||||
<div className="graph-header">
|
||||
<h2>
|
||||
{dataType === 'both' ? 'Conversation & Message Volume' :
|
||||
dataType === 'conversations' ? 'Conversation Volume' :
|
||||
'Message Volume'}
|
||||
{graphData.useAggregated ? ` (${graphData.aggregationType})` : ''}
|
||||
</h2>
|
||||
<div className="data-type-toggle">
|
||||
<button
|
||||
className={dataType === 'both' ? 'active' : ''}
|
||||
onClick={() => setDataType('both')}
|
||||
>
|
||||
Both
|
||||
</button>
|
||||
<button
|
||||
className={dataType === 'conversations' ? 'active' : ''}
|
||||
onClick={() => setDataType('conversations')}
|
||||
>
|
||||
Conversations
|
||||
</button>
|
||||
<button
|
||||
className={dataType === 'messages' ? 'active' : ''}
|
||||
onClick={() => setDataType('messages')}
|
||||
>
|
||||
Messages
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graph-container" style={{ width: '100%', height: 400 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={graphData.dataForChart} margin={{ top: 5, right: 10, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey={
|
||||
graphData.aggregationType === 'monthly' ? 'month' :
|
||||
graphData.aggregationType === 'weekly' ? 'week' :
|
||||
'date'
|
||||
}
|
||||
tickFormatter={formatXAxisTick}
|
||||
interval="auto"
|
||||
minTickGap={35}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<YAxis allowDecimals={false} domain={[0, 'dataMax + 5']} />
|
||||
<Tooltip labelFormatter={formatTooltipLabel} formatter={(value, name) => [value, name.replace(/_/g, ' ')]} />
|
||||
<Legend />
|
||||
{renderLinesOrBars()}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VolumeGraph;
|
||||
51
frontend/src/data/userDisplayNames.js
Normal file
51
frontend/src/data/userDisplayNames.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Manual mapping of email addresses to display names
|
||||
* Generated from actual Make.com data analysis
|
||||
* Used as fallback when automatic name parsing fails
|
||||
*/
|
||||
export const userDisplayNameMap = {
|
||||
// Oliver Agency Users
|
||||
"armanpreetkapoor@oliver.agency": "Armanpreet Kapoor",
|
||||
"daveporter@oliver.agency": "Dave Porter",
|
||||
"davidbrown@oliver.agency": "David Brown",
|
||||
"helenfendall@oliver.agency": "Helen Fendall",
|
||||
"jasonliew@oliver.agency": "Jason Liew",
|
||||
"jeremycrockerwhite@oliver.agency": "Jeremy Crocker White",
|
||||
"jeromejefferson@oliver.agency": "Jerome Jefferson",
|
||||
"jessicakim@oliver.agency": "Jessica Kim",
|
||||
"lisahall@oliver.agency": "Lisa Hall",
|
||||
"lloydredding@oliver.agency": "Lloyd Redding",
|
||||
"melaniecarr@oliver.agency": "Melanie Carr",
|
||||
"michaelbarratt@oliver.agency": "Michael Barratt",
|
||||
"michaelclervi@oliver.agency": "Michael Clervi",
|
||||
"mikekeenleyside@oliver.agency": "Mike Keenleyside",
|
||||
"nataliefryatt@oliver.agency": "Natalie Fryatt",
|
||||
"nigelwebb@oliver.agency": "Nigel Webb",
|
||||
"richardharman@oliver.agency": "Richard Harman",
|
||||
"richardmakepeace@oliver.agency": "Richard Makepeace",
|
||||
"robchoi@oliver.agency": "Rob Choi",
|
||||
"robkavanagh@oliver.agency": "Rob Kavanagh",
|
||||
"samiraali@oliver.agency": "Samira Ali",
|
||||
"stevehedge@oliver.agency": "Steve Hedge",
|
||||
"steveodonoghue@oliver.agency": "Steve O'Donoghue",
|
||||
"tomwilson@oliver.agency": "Tom Wilson",
|
||||
|
||||
// Barclays External Users (auto-parsed, but included for completeness)
|
||||
"adam.webb_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com": "Adam Webb",
|
||||
"aileen.stevenson_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Aileen Stevenson",
|
||||
"alistair.taylor_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Alistair Taylor",
|
||||
"angharad.slater_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com": "Angharad Slater",
|
||||
"audrey.zahlis_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Audrey Zahlis",
|
||||
"christopher.x.perkins_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Christopher X Perkins",
|
||||
"donna.hill_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Donna Hill",
|
||||
"edward.simons1_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Edward Simons",
|
||||
"evie.grant_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Evie Grant",
|
||||
"gurjit.dhaliwal_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Gurjit Dhaliwal",
|
||||
"joseph.collins_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Joseph Collins",
|
||||
"madeleine.webb_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Madeleine Webb",
|
||||
"matt.perry_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Matt Perry",
|
||||
"rachael.millard_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Rachael Millard",
|
||||
"rhiannon.bodman_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Rhiannon Bodman",
|
||||
"samantha.barnes_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Samantha Barnes",
|
||||
"zara.myers_barclays.com#ext#@olivermarketing.onmicrosoft.com": "Zara Myers"
|
||||
};
|
||||
269
frontend/src/index.css
Normal file
269
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #ffffff;
|
||||
background-color: #1b2142;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #ffffff;
|
||||
color: #213547;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.25s, border-color 0.25s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #f1f5f9;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
button.active {
|
||||
background-color: #0052a3;
|
||||
color: white;
|
||||
border-color: #003d7a;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.5em;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #d1d5db;
|
||||
font-size: 1em;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.date-presets {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.custom-date-range {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.date-input label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.export-button-container {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
border-color: #059669;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background-color: #059669;
|
||||
}
|
||||
|
||||
.export-button:disabled {
|
||||
background-color: #d1d5db;
|
||||
border-color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.volume-graph {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.graph-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.volume-graph h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.data-type-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.data-type-toggle button {
|
||||
padding: 0.3em 0.8em;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.graph-container {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Override recharts default styles */
|
||||
.recharts-cartesian-grid-horizontal line,
|
||||
.recharts-cartesian-grid-vertical line {
|
||||
stroke: #e5e7eb;
|
||||
}
|
||||
|
||||
.recharts-tooltip-wrapper {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.recharts-default-tooltip {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.recharts-tooltip-label {
|
||||
font-weight: bold !important;
|
||||
color: #1f2937 !important;
|
||||
margin-bottom: 5px !important;
|
||||
}
|
||||
|
||||
.recharts-tooltip-item-list {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.recharts-tooltip-item {
|
||||
margin: 4px 0 !important;
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.recharts-legend-wrapper {
|
||||
padding: 10px 0 !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.recharts-legend-item {
|
||||
margin-right: 20px !important;
|
||||
}
|
||||
|
||||
.recharts-legend-item-text {
|
||||
font-size: 0.875rem !important;
|
||||
color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.metrics-definitions {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background-color: #f8fafc;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.metrics-definitions h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.definition-item {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.definition-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.definition-item strong {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
17
frontend/src/main.jsx
Normal file
17
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { PublicClientApplication } from '@azure/msal-browser'
|
||||
import { MsalProvider } from '@azure/msal-react'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import { msalConfig } from './authConfig'
|
||||
|
||||
const msalInstance = new PublicClientApplication(msalConfig)
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<App />
|
||||
</MsalProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
81
frontend/src/services/api.js
Normal file
81
frontend/src/services/api.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import axios from 'axios';
|
||||
import { PublicClientApplication } from '@azure/msal-browser';
|
||||
|
||||
// Determine the API URL based on the environment
|
||||
const isDevelopment = import.meta.env.DEV;
|
||||
|
||||
// In development mode, use the local URL; in production, use the deployed URL
|
||||
const API_URL = isDevelopment
|
||||
? 'http://localhost:5001/api'
|
||||
: 'https://baic.oliver.solutions/dashboard/back/api';
|
||||
|
||||
/**
|
||||
* Get access token from MSAL instance
|
||||
* @param {PublicClientApplication} msalInstance - MSAL instance
|
||||
* @returns {Promise<string>} Access token
|
||||
*/
|
||||
const getAccessToken = async (msalInstance) => {
|
||||
try {
|
||||
console.error('DEBUG: getAccessToken called with msalInstance:', !!msalInstance);
|
||||
|
||||
if (!msalInstance) {
|
||||
console.error('DEBUG: MSAL instance is null or undefined');
|
||||
throw new Error('MSAL instance is null or undefined');
|
||||
}
|
||||
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
console.error('DEBUG: Found accounts:', accounts.length);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.error('DEBUG: No accounts found - user may need to login');
|
||||
throw new Error('No accounts found');
|
||||
}
|
||||
|
||||
const request = {
|
||||
scopes: ['User.Read'],
|
||||
account: accounts[0]
|
||||
};
|
||||
|
||||
console.error('DEBUG: Token request scopes:', request.scopes);
|
||||
const response = await msalInstance.acquireTokenSilent(request);
|
||||
console.error('DEBUG: Token acquired successfully:', !!response.accessToken);
|
||||
return response.accessToken;
|
||||
} catch (error) {
|
||||
console.error('ERROR in getAccessToken:', error);
|
||||
console.error('ERROR details:', error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all data (conversations and messages) from the backend API
|
||||
* @param {PublicClientApplication} msalInstance - MSAL instance for authentication
|
||||
* @returns {Promise} Promise resolving to the API response containing conversations and messages
|
||||
*/
|
||||
export const fetchAllData = async (msalInstance = null) => {
|
||||
try {
|
||||
console.error('DEBUG: fetchAllData called with msalInstance:', !!msalInstance);
|
||||
const headers = {};
|
||||
|
||||
// Add authorization header if MSAL instance is provided
|
||||
if (msalInstance) {
|
||||
console.error('DEBUG: Attempting to get access token...');
|
||||
const accessToken = await getAccessToken(msalInstance);
|
||||
headers.Authorization = `Bearer ${accessToken}`;
|
||||
console.error('DEBUG: Authorization header set:', !!headers.Authorization);
|
||||
console.error('DEBUG: Token length:', accessToken ? accessToken.length : 0);
|
||||
} else {
|
||||
console.error('DEBUG: No MSAL instance provided - no authorization header will be set');
|
||||
}
|
||||
|
||||
console.error('DEBUG: Making API request to:', `${API_URL}/data`);
|
||||
console.error('DEBUG: Headers being sent:', Object.keys(headers));
|
||||
const response = await axios.get(`${API_URL}/data`, { headers });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('ERROR fetching data:', error);
|
||||
console.error('ERROR response status:', error.response?.status);
|
||||
console.error('ERROR response data:', error.response?.data);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
29
frontend/src/services/assistantMapping.js
Normal file
29
frontend/src/services/assistantMapping.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Mapping of Assistant IDs to friendly display names
|
||||
*/
|
||||
export const assistantMapping = {
|
||||
'asst_Pz7uhnK7aOoYykl7KalyirY9': 'PPC',
|
||||
'asst_MT0qKXI57m8Y2RVllqwFUqBe': 'Social',
|
||||
'asst_vlFx0Uud1BKtp7j77Vp0pi8H': 'Internal Banners',
|
||||
'asst_iiqnQ0w6l5eQAQtLJxDTklwK': 'FreeChat',
|
||||
'asst_eAsIXFpSGiy7jQzyF8p0IRDA': 'Display Banners',
|
||||
'asst_l9G2nl9TacmuheBsueRxTznc': 'Email (beta)'
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a friendly display name for a given Assistant ID
|
||||
* @param {string} assistantId - The Assistant ID
|
||||
* @returns {string|null} - The friendly display name or null if not found
|
||||
*/
|
||||
export const getAssistantDisplayName = (assistantId) => {
|
||||
return assistantMapping[assistantId] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an Assistant ID has a friendly name mapping
|
||||
* @param {string} assistantId - The Assistant ID
|
||||
* @returns {boolean} - True if the ID has a friendly name, false otherwise
|
||||
*/
|
||||
export const hasAssistantFriendlyName = (assistantId) => {
|
||||
return assistantId in assistantMapping;
|
||||
};
|
||||
184
frontend/src/services/userMapping.js
Normal file
184
frontend/src/services/userMapping.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* User mapping utilities for processing user data from email addresses
|
||||
* Handles organization detection, friendly name extraction, and technical suffix cleanup
|
||||
*/
|
||||
|
||||
import { userDisplayNameMap } from '../data/userDisplayNames.js';
|
||||
|
||||
/**
|
||||
* Extract organization from email address based on domain content
|
||||
* @param {string} email - The email address to analyze
|
||||
* @returns {string} - 'Oliver' | 'Barclays' | 'Other'
|
||||
*/
|
||||
export const getOrganizationFromEmail = (email) => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
const lowerEmail = email.toLowerCase();
|
||||
|
||||
if (lowerEmail.includes('barclay')) {
|
||||
return 'Barclays';
|
||||
}
|
||||
|
||||
if (lowerEmail.includes('@oliver.agency')) {
|
||||
return 'Oliver';
|
||||
}
|
||||
|
||||
return 'Other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip technical suffixes from email addresses
|
||||
* Handles external user patterns from Barclays and other external domains
|
||||
* @param {string} email - The email address to clean
|
||||
* @returns {string} - Cleaned email address
|
||||
*/
|
||||
export const stripTechnicalSuffixes = (email) => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Remove external user patterns:
|
||||
// - _barclays.com#ext#@olivermarketing.onmicrosoft.com
|
||||
// - _barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com
|
||||
// - Any similar _domain#ext#@... patterns
|
||||
return email
|
||||
.replace(/_barclays\.com#ext#@olivermarketing\.onmicrosoft\.com$/, '')
|
||||
.replace(/_barclaycard\.co\.uk#ext#@olivermarketing\.onmicrosoft\.com$/, '')
|
||||
.replace(/_[^_]+\.(com|co\.uk)#ext#@olivermarketing\.onmicrosoft\.com$/, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract friendly name from email prefix
|
||||
* Converts email prefix to "FirstName LastName" format
|
||||
* Handles both Oliver agency camelCase and external dot.separated formats
|
||||
* @param {string} email - The email address to parse
|
||||
* @returns {string} - Formatted friendly name
|
||||
*/
|
||||
export const getFriendlyNameFromEmail = (email) => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return email || 'Unknown User';
|
||||
}
|
||||
|
||||
// First check manual mapping for exact matches
|
||||
if (userDisplayNameMap[email.toLowerCase()]) {
|
||||
return userDisplayNameMap[email.toLowerCase()];
|
||||
}
|
||||
|
||||
// Strip technical suffixes first
|
||||
const cleanEmail = stripTechnicalSuffixes(email);
|
||||
|
||||
// Extract the part before @
|
||||
const prefix = cleanEmail.split('@')[0];
|
||||
|
||||
if (!prefix) {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Handle Oliver agency emails (camelCase format like 'michaelclervi')
|
||||
if (email.includes('@oliver.agency')) {
|
||||
// Try to split camelCase into words
|
||||
const words = prefix.match(/[A-Z][a-z]*|[a-z]+/g) || [prefix];
|
||||
if (words.length >= 2) {
|
||||
return words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
|
||||
} else {
|
||||
// Single word, just capitalize first letter
|
||||
return prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase();
|
||||
}
|
||||
} else {
|
||||
// Handle external emails (dot.separated format like 'adam.webb')
|
||||
const spaced = prefix.replace(/[._-]/g, ' ');
|
||||
|
||||
// Title case each word
|
||||
const titleCased = spaced
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return titleCased;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Process raw user list into organized structure for filtering
|
||||
* @param {string[]} users - Array of raw User_ID values (emails)
|
||||
* @returns {Object} - Processed user data with organization grouping
|
||||
*/
|
||||
export const processUserData = (users) => {
|
||||
if (!Array.isArray(users)) {
|
||||
return {
|
||||
byOrganization: {
|
||||
'Oliver': [],
|
||||
'Barclays': [],
|
||||
'Other': []
|
||||
},
|
||||
friendlyNameMap: {},
|
||||
organizationMap: {}
|
||||
};
|
||||
}
|
||||
|
||||
const byOrganization = {
|
||||
'Oliver': [],
|
||||
'Barclays': [],
|
||||
'Other': []
|
||||
};
|
||||
|
||||
const friendlyNameMap = {}; // email -> friendly name
|
||||
const organizationMap = {}; // email -> organization
|
||||
|
||||
users.forEach(email => {
|
||||
if (!email) return;
|
||||
|
||||
const organization = getOrganizationFromEmail(email);
|
||||
const friendlyName = getFriendlyNameFromEmail(email);
|
||||
|
||||
byOrganization[organization].push(email);
|
||||
friendlyNameMap[email] = friendlyName;
|
||||
organizationMap[email] = organization;
|
||||
});
|
||||
|
||||
// Sort users within each organization by friendly name
|
||||
Object.keys(byOrganization).forEach(org => {
|
||||
byOrganization[org].sort((a, b) => {
|
||||
const nameA = friendlyNameMap[a].toLowerCase();
|
||||
const nameB = friendlyNameMap[b].toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
byOrganization,
|
||||
friendlyNameMap,
|
||||
organizationMap
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get filtered user list based on organization selection
|
||||
* @param {Object} processedUserData - Result from processUserData()
|
||||
* @param {string} organizationFilter - 'All Users' | 'Oliver Users' | 'Barclays Users'
|
||||
* @returns {string[]} - Filtered array of user emails
|
||||
*/
|
||||
export const getFilteredUsers = (processedUserData, organizationFilter) => {
|
||||
if (!processedUserData || !processedUserData.byOrganization) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { byOrganization } = processedUserData;
|
||||
|
||||
switch (organizationFilter) {
|
||||
case 'Oliver Users':
|
||||
return byOrganization['Oliver'] || [];
|
||||
case 'Barclays Users':
|
||||
return byOrganization['Barclays'] || [];
|
||||
case 'All Users':
|
||||
default:
|
||||
return [
|
||||
...(byOrganization['Oliver'] || []),
|
||||
...(byOrganization['Barclays'] || []),
|
||||
...(byOrganization['Other'] || [])
|
||||
];
|
||||
}
|
||||
};
|
||||
50
frontend/src/utils/brandMapping.js
Normal file
50
frontend/src/utils/brandMapping.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Brand/TOV mapping utility for transforming raw brand values to display-friendly names
|
||||
*/
|
||||
|
||||
// Mapping from raw brand values to display names
|
||||
export const BRAND_DISPLAY_MAPPING = {
|
||||
'standard': 'Barclays',
|
||||
'pep': 'Barclaycard'
|
||||
};
|
||||
|
||||
// Reverse mapping from display names to raw values
|
||||
export const DISPLAY_TO_BRAND_MAPPING = Object.fromEntries(
|
||||
Object.entries(BRAND_DISPLAY_MAPPING).map(([key, value]) => [value, key])
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert raw brand value to display name
|
||||
* @param {string} rawValue - The raw brand value from the data
|
||||
* @returns {string} - The display-friendly name or the original value if no mapping exists
|
||||
*/
|
||||
export const getBrandDisplayName = (rawValue) => {
|
||||
return BRAND_DISPLAY_MAPPING[rawValue] || rawValue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert display name back to raw brand value
|
||||
* @param {string} displayName - The display name shown to users
|
||||
* @returns {string} - The raw brand value or the original display name if no mapping exists
|
||||
*/
|
||||
export const getBrandRawValue = (displayName) => {
|
||||
return DISPLAY_TO_BRAND_MAPPING[displayName] || displayName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform an array of raw brand values to display names
|
||||
* @param {string[]} rawValues - Array of raw brand values
|
||||
* @returns {string[]} - Array of display names
|
||||
*/
|
||||
export const transformBrandValuesToDisplay = (rawValues) => {
|
||||
return rawValues.map(getBrandDisplayName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a raw brand value has a display mapping
|
||||
* @param {string} rawValue - The raw brand value to check
|
||||
* @returns {boolean} - True if a mapping exists
|
||||
*/
|
||||
export const hasBrandMapping = (rawValue) => {
|
||||
return rawValue in BRAND_DISPLAY_MAPPING;
|
||||
};
|
||||
305
frontend/src/utils/dataAggregation.js
Normal file
305
frontend/src/utils/dataAggregation.js
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { parseISO, format, startOfDay, differenceInDays, isValid, addDays } from 'date-fns';
|
||||
import { getAssistantDisplayName, hasAssistantFriendlyName } from '../services/assistantMapping';
|
||||
import { getBrandDisplayName } from './brandMapping';
|
||||
|
||||
/**
|
||||
* Generate aggregated statistical data for conversations and messages
|
||||
* This utility creates time-series data that can be used for both visualization and CSV export
|
||||
*
|
||||
* @param {Array} filteredConversations - Filtered conversation data
|
||||
* @param {Array} filteredMessages - Filtered message data
|
||||
* @param {Array} uniqueAssistants - Array of unique assistant IDs
|
||||
* @param {Object} filters - Current filter state from dashboard
|
||||
* @returns {Object} Aggregated data with daily/weekly/monthly breakdowns
|
||||
*/
|
||||
export const generateAggregatedData = (filteredConversations, filteredMessages, uniqueAssistants, filters) => {
|
||||
if (!filteredConversations.length && !filteredMessages.length) {
|
||||
return {
|
||||
chartData: [],
|
||||
exportData: [],
|
||||
aggregationType: 'daily',
|
||||
dateRange: null
|
||||
};
|
||||
}
|
||||
|
||||
// Get all timestamps for date range calculation
|
||||
const allTimestamps = [
|
||||
...filteredConversations.map(conv => conv.StartTime),
|
||||
...filteredMessages.map(msg => msg.Timestamp)
|
||||
].filter(Boolean);
|
||||
|
||||
const validDates = allTimestamps
|
||||
.map(dateStr => {
|
||||
try { return parseISO(dateStr); } catch { return null; }
|
||||
})
|
||||
.filter(dateObj => dateObj && isValid(dateObj));
|
||||
|
||||
if (validDates.length === 0) {
|
||||
return {
|
||||
chartData: [],
|
||||
exportData: [],
|
||||
aggregationType: 'daily',
|
||||
dateRange: null
|
||||
};
|
||||
}
|
||||
|
||||
const minDate = startOfDay(new Date(Math.min(...validDates)));
|
||||
const maxDate = startOfDay(new Date(Math.max(...validDates)));
|
||||
const daysDiff = differenceInDays(maxDate, minDate) + 1;
|
||||
|
||||
// Determine aggregation type based on date range
|
||||
const useWeekly = daysDiff > 60 && daysDiff <= 180;
|
||||
const useMonthly = daysDiff > 180;
|
||||
let aggregationType = 'daily';
|
||||
if (useMonthly) {
|
||||
aggregationType = 'monthly';
|
||||
} else if (useWeekly) {
|
||||
aggregationType = 'weekly';
|
||||
}
|
||||
|
||||
// Initialize data structures
|
||||
const dataByDate = {};
|
||||
const monthlyData = {};
|
||||
const weeklyData = {};
|
||||
|
||||
for (let i = 0; i < daysDiff; i++) {
|
||||
const currentDate = addDays(minDate, i);
|
||||
const dateKey = format(currentDate, 'yyyy-MM-dd');
|
||||
const monthKey = format(currentDate, 'yyyy-MM');
|
||||
|
||||
const dayOffsetFromMin = differenceInDays(currentDate, minDate);
|
||||
const weekNumber = Math.floor(dayOffsetFromMin / 7) + 1;
|
||||
const weekKey = `Week ${weekNumber}`;
|
||||
|
||||
// Daily data
|
||||
dataByDate[dateKey] = {
|
||||
date: dateKey,
|
||||
conversationsTotal: 0,
|
||||
messagesTotal: 0,
|
||||
details: [] // Will store breakdown by assistant/user/brand
|
||||
};
|
||||
|
||||
// Initialize assistant counters
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
dataByDate[dateKey][`conversations_${assistantId}`] = 0;
|
||||
dataByDate[dateKey][`messages_${assistantId}`] = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Monthly data
|
||||
if (!monthlyData[monthKey]) {
|
||||
monthlyData[monthKey] = {
|
||||
month: monthKey,
|
||||
conversationsTotal: 0,
|
||||
messagesTotal: 0,
|
||||
details: []
|
||||
};
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
monthlyData[monthKey][`conversations_${assistantId}`] = 0;
|
||||
monthlyData[monthKey][`messages_${assistantId}`] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly data
|
||||
if (!weeklyData[weekKey]) {
|
||||
const weekStartDate = addDays(minDate, (weekNumber - 1) * 7);
|
||||
weeklyData[weekKey] = {
|
||||
week: weekKey,
|
||||
startDateFormatted: format(weekStartDate, 'MMM d, yyyy'),
|
||||
conversationsTotal: 0,
|
||||
messagesTotal: 0,
|
||||
details: []
|
||||
};
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
weeklyData[weekKey][`conversations_${assistantId}`] = 0;
|
||||
weeklyData[weekKey][`messages_${assistantId}`] = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate conversation counts
|
||||
filteredConversations.forEach(conv => {
|
||||
if (conv.StartTime) {
|
||||
const parsedDate = parseISO(conv.StartTime);
|
||||
if (isValid(parsedDate)) {
|
||||
const dateStr = format(parsedDate, 'yyyy-MM-dd');
|
||||
const monthStr = format(parsedDate, 'yyyy-MM');
|
||||
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
|
||||
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
|
||||
const weekKeyAgg = `Week ${weekNumAgg}`;
|
||||
|
||||
// Aggregate totals
|
||||
if (dataByDate[dateStr]) {
|
||||
dataByDate[dateStr].conversationsTotal++;
|
||||
if (conv.Assistant_ID && dataByDate[dateStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
|
||||
dataByDate[dateStr][`conversations_${conv.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (monthlyData[monthStr]) {
|
||||
monthlyData[monthStr].conversationsTotal++;
|
||||
if (conv.Assistant_ID && monthlyData[monthStr][`conversations_${conv.Assistant_ID}`] !== undefined) {
|
||||
monthlyData[monthStr][`conversations_${conv.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (weeklyData[weekKeyAgg]) {
|
||||
weeklyData[weekKeyAgg].conversationsTotal++;
|
||||
if (conv.Assistant_ID && weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`] !== undefined) {
|
||||
weeklyData[weekKeyAgg][`conversations_${conv.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Aggregate message counts
|
||||
filteredMessages.forEach(msg => {
|
||||
if (msg.Timestamp) {
|
||||
const parsedDate = parseISO(msg.Timestamp);
|
||||
if (isValid(parsedDate)) {
|
||||
const dateStr = format(parsedDate, 'yyyy-MM-dd');
|
||||
const monthStr = format(parsedDate, 'yyyy-MM');
|
||||
const dayOffsetFromMinAgg = differenceInDays(parsedDate, minDate);
|
||||
const weekNumAgg = Math.floor(dayOffsetFromMinAgg / 7) + 1;
|
||||
const weekKeyAgg = `Week ${weekNumAgg}`;
|
||||
|
||||
// Aggregate totals
|
||||
if (dataByDate[dateStr]) {
|
||||
dataByDate[dateStr].messagesTotal++;
|
||||
if (msg.Assistant_ID && dataByDate[dateStr][`messages_${msg.Assistant_ID}`] !== undefined) {
|
||||
dataByDate[dateStr][`messages_${msg.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (monthlyData[monthStr]) {
|
||||
monthlyData[monthStr].messagesTotal++;
|
||||
if (msg.Assistant_ID && monthlyData[monthStr][`messages_${msg.Assistant_ID}`] !== undefined) {
|
||||
monthlyData[monthStr][`messages_${msg.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
if (weeklyData[weekKeyAgg]) {
|
||||
weeklyData[weekKeyAgg].messagesTotal++;
|
||||
if (msg.Assistant_ID && weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`] !== undefined) {
|
||||
weeklyData[weekKeyAgg][`messages_${msg.Assistant_ID}`]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to arrays and sort
|
||||
const dailyDataArray = Object.values(dataByDate).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const monthlyDataArray = Object.values(monthlyData).sort((a, b) => a.month.localeCompare(b.month));
|
||||
const weeklyDataArray = Object.values(weeklyData).sort((a,b) => {
|
||||
const weekNumA = parseInt(a.week.split(' ')[1]);
|
||||
const weekNumB = parseInt(b.week.split(' ')[1]);
|
||||
return weekNumA - weekNumB;
|
||||
});
|
||||
|
||||
// Select the appropriate data based on aggregation type
|
||||
let chartData = dailyDataArray;
|
||||
if (aggregationType === 'monthly') {
|
||||
chartData = monthlyDataArray;
|
||||
} else if (aggregationType === 'weekly') {
|
||||
chartData = weeklyDataArray;
|
||||
}
|
||||
|
||||
// Generate export data - flatten the aggregated data for CSV export
|
||||
const exportData = generateExportData(chartData, aggregationType, filters, uniqueAssistants);
|
||||
|
||||
return {
|
||||
chartData,
|
||||
exportData,
|
||||
aggregationType,
|
||||
dateRange: { minDate, maxDate, daysDiff }
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate flattened data for CSV export
|
||||
* Creates rows with columns: Date, Period_Type, Assistant Name, Brand, User_Organization, Conversation_Count, Message_Count
|
||||
*/
|
||||
const generateExportData = (chartData, aggregationType, filters, uniqueAssistants) => {
|
||||
const exportRows = [];
|
||||
|
||||
chartData.forEach(dataPoint => {
|
||||
// Get the appropriate date field based on aggregation type
|
||||
let dateValue, periodType;
|
||||
if (aggregationType === 'monthly') {
|
||||
dateValue = dataPoint.month;
|
||||
periodType = 'Monthly';
|
||||
} else if (aggregationType === 'weekly') {
|
||||
dateValue = dataPoint.week;
|
||||
periodType = 'Weekly';
|
||||
} else {
|
||||
dateValue = dataPoint.date;
|
||||
periodType = 'Daily';
|
||||
}
|
||||
|
||||
// If specific assistant is selected, only export data for that assistant
|
||||
if (filters.assistant && filters.assistant !== 'All Assistants') {
|
||||
// Get the assistant ID from the display name
|
||||
const assistantId = window.assistantDisplayToIdMap?.[filters.assistant] || filters.assistant;
|
||||
|
||||
// Only export if assistant has a friendly name
|
||||
if (hasAssistantFriendlyName(assistantId)) {
|
||||
const assistantDisplayName = getAssistantDisplayName(assistantId);
|
||||
|
||||
const conversationCount = dataPoint[`conversations_${assistantId}`] || 0;
|
||||
const messageCount = dataPoint[`messages_${assistantId}`] || 0;
|
||||
|
||||
exportRows.push({
|
||||
Date: dateValue,
|
||||
Period_Type: periodType,
|
||||
'Assistant Name': assistantDisplayName,
|
||||
Brand: filters.brandTOV !== 'All Brands/TOV' ? getBrandDisplayName(filters.brandTOV) : 'All Brands',
|
||||
User_Organization: filters.organization !== 'All Users' ? filters.organization : 'All Organizations',
|
||||
Conversation_Count: conversationCount,
|
||||
Message_Count: messageCount
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Export data for all assistants or totals
|
||||
if (uniqueAssistants && uniqueAssistants.length > 0) {
|
||||
// Create separate rows for each assistant
|
||||
uniqueAssistants.forEach(assistantId => {
|
||||
// Only export assistants with friendly names
|
||||
if (hasAssistantFriendlyName(assistantId)) {
|
||||
const assistantDisplayName = getAssistantDisplayName(assistantId);
|
||||
const conversationCount = dataPoint[`conversations_${assistantId}`] || 0;
|
||||
const messageCount = dataPoint[`messages_${assistantId}`] || 0;
|
||||
|
||||
// Only include rows with actual data
|
||||
if (conversationCount > 0 || messageCount > 0) {
|
||||
exportRows.push({
|
||||
Date: dateValue,
|
||||
Period_Type: periodType,
|
||||
'Assistant Name': assistantDisplayName,
|
||||
Brand: filters.brandTOV !== 'All Brands/TOV' ? getBrandDisplayName(filters.brandTOV) : 'All Brands',
|
||||
User_Organization: filters.organization !== 'All Users' ? filters.organization : 'All Organizations',
|
||||
Conversation_Count: conversationCount,
|
||||
Message_Count: messageCount
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also add total row
|
||||
exportRows.push({
|
||||
Date: dateValue,
|
||||
Period_Type: periodType,
|
||||
'Assistant Name': 'All Assistants',
|
||||
Brand: filters.brandTOV !== 'All Brands/TOV' ? getBrandDisplayName(filters.brandTOV) : 'All Brands',
|
||||
User_Organization: filters.organization !== 'All Users' ? filters.organization : 'All Organizations',
|
||||
Conversation_Count: dataPoint.conversationsTotal || 0,
|
||||
Message_Count: dataPoint.messagesTotal || 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return exportRows;
|
||||
};
|
||||
8
frontend/vite.config.js
Normal file
8
frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/dashboard/',
|
||||
})
|
||||
31
run.sh
Executable file
31
run.sh
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Start the backend server with Hypercorn
|
||||
echo "Starting backend server with Hypercorn..."
|
||||
cd backend
|
||||
python app.py &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait a bit for the backend to start
|
||||
sleep 2
|
||||
|
||||
# Start the frontend dev server
|
||||
echo "Starting frontend dev server..."
|
||||
cd ../frontend
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
# Function to handle script termination
|
||||
cleanup() {
|
||||
echo "Shutting down servers..."
|
||||
kill $FRONTEND_PID
|
||||
kill $BACKEND_PID
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Set up trap to handle Ctrl+C and other termination signals
|
||||
trap cleanup INT TERM
|
||||
|
||||
# Keep the script running
|
||||
echo "Both servers are running. Press Ctrl+C to stop."
|
||||
wait
|
||||
69
test_user_mapping.js
Normal file
69
test_user_mapping.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Test the current userMapping logic with real email data
|
||||
const stripTechnicalSuffixes = (email) => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return email;
|
||||
}
|
||||
|
||||
return email
|
||||
.replace(/_barclays\.com#ext#@olivermarketing\.onmicrosoft\.com$/, '')
|
||||
.replace(/_barclaycard\.co\.uk#ext#@olivermarketing\.onmicrosoft\.com$/, '')
|
||||
.replace(/_[^_]+\.(com|co\.uk)#ext#@olivermarketing\.onmicrosoft\.com$/, '');
|
||||
};
|
||||
|
||||
const getFriendlyNameFromEmail = (email) => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return email || 'Unknown User';
|
||||
}
|
||||
|
||||
const cleanEmail = stripTechnicalSuffixes(email);
|
||||
const prefix = cleanEmail.split('@')[0];
|
||||
|
||||
if (!prefix) {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Handle Oliver agency emails (camelCase format like 'michaelclervi')
|
||||
if (email.includes('@oliver.agency')) {
|
||||
// Try to split camelCase into words
|
||||
const words = prefix.match(/[A-Z][a-z]*|[a-z]+/g) || [prefix];
|
||||
if (words.length >= 2) {
|
||||
return words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
|
||||
} else {
|
||||
// Single word, just capitalize first letter
|
||||
return prefix.charAt(0).toUpperCase() + prefix.slice(1).toLowerCase();
|
||||
}
|
||||
} else {
|
||||
// Handle external emails (dot.separated format like 'adam.webb')
|
||||
const spaced = prefix.replace(/[._-]/g, ' ');
|
||||
|
||||
// Title case each word
|
||||
const titleCased = spaced
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
return titleCased;
|
||||
}
|
||||
};
|
||||
|
||||
// Test with actual email patterns
|
||||
const testEmails = [
|
||||
'michaelclervi@oliver.agency',
|
||||
'adam.webb_barclaycard.co.uk#ext#@olivermarketing.onmicrosoft.com',
|
||||
'aileen.stevenson_barclays.com#ext#@olivermarketing.onmicrosoft.com',
|
||||
'christopher.x.perkins_barclays.com#ext#@olivermarketing.onmicrosoft.com',
|
||||
'jeremycrockerwhite@oliver.agency',
|
||||
'armanpreetkapoor@oliver.agency',
|
||||
'daveporter@oliver.agency'
|
||||
];
|
||||
|
||||
console.log('Testing email name extraction:');
|
||||
testEmails.forEach(email => {
|
||||
const stripped = stripTechnicalSuffixes(email);
|
||||
const friendly = getFriendlyNameFromEmail(email);
|
||||
console.log(`${email}`);
|
||||
console.log(` Stripped: ${stripped}`);
|
||||
console.log(` Friendly: ${friendly}`);
|
||||
console.log('');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue