initial commit
This commit is contained in:
commit
e97d0e935c
117 changed files with 21379 additions and 0 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Environment files with secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Build outputs
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Mod Comms is an AI-powered proof review tool for Barclays marketing materials. It uses multiple AI agents to analyze uploaded proofs (marketing assets) for legal compliance, brand adherence, tone of voice, and channel suitability.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # Install dependencies
|
||||
npm run dev # Start dev server on port 3000
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Create `frontend/.env.local` with:
|
||||
```
|
||||
GEMINI_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Structure (`frontend/`)
|
||||
- **App.tsx** - Main application component, handles routing between views and manages global state (campaigns, proofs, flagged/resolved/error items)
|
||||
- **components/** - React components for each view and UI element
|
||||
- **services/geminiService.ts** - Google Gemini AI integration for proof analysis
|
||||
|
||||
### Multi-Agent System
|
||||
Four specialist AI agents analyze each proof:
|
||||
- **Legal Agent** - Checks advertising standards, disclaimers, financial promotion detection
|
||||
- **Brand Agent** - Verifies brand guidelines (logos, colors, branding)
|
||||
- **Tone Agent** - Analyzes copy clarity, brand personality, grammar
|
||||
- **Channel Agent** - Assesses technical suitability for digital channels
|
||||
|
||||
A **Lead Agent** mediates specialist feedback and determines overall RAG status:
|
||||
- Green: All pass, max 1 amber issue per agent (except Legal amber triggers overall Amber)
|
||||
- Amber: >1 actionable issue per agent, or any Legal amber
|
||||
- Red: Failure from any agent
|
||||
|
||||
### Key Data Types (`types.ts`)
|
||||
- `AgentReview` - Complete analysis result with all agent reviews
|
||||
- `RagStatus` - 'Red' | 'Amber' | 'Green' | 'Error'
|
||||
- `OverallStatus` - 'Passed' | 'Failed' | 'Analysis Error' | 'Requires Manual Legal Review'
|
||||
|
||||
### State Persistence
|
||||
Application state persists to localStorage with `barclays_modcomms_*_v3` keys. Large preview URLs are stripped before saving to avoid quota errors.
|
||||
|
||||
### User Roles
|
||||
- **Admin** - Full access to all campaigns, Settings, Analytics, Auditing
|
||||
- **Basic User** - Limited to own agency's campaigns
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
Brand and channel guidelines are in `reference_docs/`:
|
||||
- `brand/` - Barclays/Barclaycard brand guidelines, tone of voice, design principles
|
||||
- `channel/` - Digital and social media guidelines
|
||||
|
||||
Product requirements specification: `specs/Barclays_ModComms_Req_v5.md`
|
||||
15
backend/.env.example
Normal file
15
backend/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Gemini API Configuration
|
||||
# Get your API key from: https://aistudio.google.com/app/apikey
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
# Reference Documents Path (optional)
|
||||
# Defaults to ../reference_docs relative to backend/
|
||||
# REFERENCE_DOCS_PATH=/path/to/reference_docs
|
||||
|
||||
# CORS Configuration
|
||||
# Comma-separated list of allowed origins
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# ModComms Backend Application
|
||||
6
backend/app/agents/__init__.py
Normal file
6
backend/app/agents/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .base_agent import BaseAgent
|
||||
from .brand_agent import BrandAgent
|
||||
from .channel_agent import ChannelAgent
|
||||
from .legal_agent import LegalAgent
|
||||
from .tone_agent import ToneAgent
|
||||
from .lead_agent import LeadAgent
|
||||
22
backend/app/agents/base_agent.py
Normal file
22
backend/app/agents/base_agent.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from app.models.schemas import SubReview
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""Abstract base class for all review agents."""
|
||||
|
||||
name: str = "Base Agent"
|
||||
|
||||
@abstractmethod
|
||||
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
|
||||
"""
|
||||
Analyze the proof and return a SubReview.
|
||||
|
||||
Args:
|
||||
file_data: Raw bytes of the file to analyze
|
||||
file_type: MIME type of the file
|
||||
|
||||
Returns:
|
||||
SubReview containing ragStatus, feedback, and issues
|
||||
"""
|
||||
pass
|
||||
66
backend/app/agents/brand_agent.py
Normal file
66
backend/app/agents/brand_agent.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from app.agents.base_agent import BaseAgent
|
||||
from app.models.schemas import SubReview
|
||||
from app.services.gemini_service import GeminiService
|
||||
from app.services.reference_docs import ReferenceDocsService
|
||||
|
||||
|
||||
class BrandAgent(BaseAgent):
|
||||
"""Brand Agent - analyzes proofs against Barclays brand guidelines using Gemini."""
|
||||
|
||||
name = "Brand Agent"
|
||||
|
||||
def __init__(self, gemini_service: GeminiService, reference_docs: ReferenceDocsService):
|
||||
"""
|
||||
Initialize the Brand Agent.
|
||||
|
||||
Args:
|
||||
gemini_service: Service for making Gemini API calls
|
||||
reference_docs: Service for loading reference documents
|
||||
"""
|
||||
self.gemini = gemini_service
|
||||
self.brand_context = reference_docs.get_brand_context()
|
||||
|
||||
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
|
||||
"""
|
||||
Analyze the proof for brand guideline adherence.
|
||||
|
||||
Args:
|
||||
file_data: Raw bytes of the file to analyze
|
||||
file_type: MIME type of the file
|
||||
|
||||
Returns:
|
||||
SubReview with brand compliance assessment
|
||||
"""
|
||||
prompt = f"""You are a brand expert for Barclays Bank. Your role is to analyze marketing proofs for adherence to Barclays UK brand guidelines.
|
||||
|
||||
Here are the brand guidelines to use for your analysis:
|
||||
|
||||
{self.brand_context}
|
||||
|
||||
---
|
||||
|
||||
Analyze the uploaded proof against these Barclays brand guidelines, checking for:
|
||||
|
||||
1. **Logo Usage**: Is the Barclays Eagle/logo used correctly? Check sizing, placement (should be top-right or as specified), clear space requirements, and that it hasn't been altered.
|
||||
|
||||
2. **Card Portal**: If the Card Portal asset is present, verify it follows the sizing rules (1/12 of shortest side for print, responsive for digital), proper cyan color, and corner visibility.
|
||||
|
||||
3. **Color Palette**: Are only approved brand colors used? Check for proper color pairings and that cyan is used appropriately as a sacred asset.
|
||||
|
||||
4. **Typography**: Is Barclays Effra (or approved fallback) used correctly? Check font weights, sizes, and hierarchy.
|
||||
|
||||
5. **Design Principles**: Does the overall design reflect the brand principles of Bold, Purposeful, and Expressive?
|
||||
|
||||
6. **Sacred Assets**: Verify that sacred assets (Cyan, Logo/Eagle, Portal) are not altered or misused.
|
||||
|
||||
Provide your analysis as a JSON object. Be specific about any issues found and reference the relevant guideline sections.
|
||||
|
||||
RAG Status Guidelines:
|
||||
- **Green**: Fully compliant with brand guidelines, no issues
|
||||
- **Amber**: Minor deviations that should be addressed but don't severely impact brand integrity
|
||||
- **Red**: Significant brand guideline violations that must be fixed before use
|
||||
|
||||
If the proof is nonsensical, not a marketing material, or cannot be analyzed, set analysisStatus to 'low_confidence'.
|
||||
"""
|
||||
|
||||
return await self.gemini.analyze_with_image(prompt, file_data, file_type)
|
||||
84
backend/app/agents/channel_agent.py
Normal file
84
backend/app/agents/channel_agent.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
from app.agents.base_agent import BaseAgent
|
||||
from app.models.schemas import SubReview
|
||||
from app.services.gemini_service import GeminiService
|
||||
from app.services.reference_docs import ReferenceDocsService
|
||||
|
||||
|
||||
class ChannelAgent(BaseAgent):
|
||||
"""Channel Agent - analyzes proofs for digital channel suitability using Gemini."""
|
||||
|
||||
name = "Channel Agent"
|
||||
|
||||
def __init__(self, gemini_service: GeminiService, reference_docs: ReferenceDocsService):
|
||||
"""
|
||||
Initialize the Channel Agent.
|
||||
|
||||
Args:
|
||||
gemini_service: Service for making Gemini API calls
|
||||
reference_docs: Service for loading reference documents
|
||||
"""
|
||||
self.gemini = gemini_service
|
||||
self.channel_context = reference_docs.get_channel_context()
|
||||
|
||||
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
|
||||
"""
|
||||
Analyze the proof for channel suitability.
|
||||
|
||||
Args:
|
||||
file_data: Raw bytes of the file to analyze
|
||||
file_type: MIME type of the file
|
||||
|
||||
Returns:
|
||||
SubReview with channel suitability assessment
|
||||
"""
|
||||
prompt = f"""You are a digital channel specialist for Barclays Bank. Your role is to analyze marketing proofs for technical suitability across digital and social media channels.
|
||||
|
||||
Here are the channel guidelines to use for your analysis:
|
||||
|
||||
{self.channel_context}
|
||||
|
||||
---
|
||||
|
||||
Analyze the uploaded proof for technical suitability for its intended digital channel, checking:
|
||||
|
||||
1. **Social Media Compliance** (if applicable):
|
||||
- Logo placement (should be top-right corner, 40px from edges on social)
|
||||
- Portal sizing for different platforms (8px thin, 16px thin, or standard as appropriate)
|
||||
- Format specifications (proper dimensions for the target platform)
|
||||
|
||||
2. **Digital Grid System**:
|
||||
- Desktop: 12-column grid
|
||||
- Tablet: 12-column grid
|
||||
- Mobile: 6-column grid
|
||||
- 8px baseline grid adherence
|
||||
|
||||
3. **Typography Scale**:
|
||||
- Check if text sizing follows the 8-level scale (Supersize 80px down to X Small 12px)
|
||||
- Responsive type considerations for different breakpoints (640px mobile threshold)
|
||||
|
||||
4. **Accessibility**:
|
||||
- Color contrast meets accessibility requirements
|
||||
- Only documented color pairings are used
|
||||
- Text is readable at the intended display size
|
||||
|
||||
5. **Platform-Specific Requirements**:
|
||||
- Hashtag usage guidelines (if social media)
|
||||
- Emoji usage (appropriate vs. inappropriate as per guidelines)
|
||||
- Character limits (Headlines: 65 chars, Body: 300 chars, Quotations: 250 chars)
|
||||
|
||||
6. **Motion/Video** (if applicable):
|
||||
- Start and end frame compliance
|
||||
- Subtitle formatting
|
||||
- Frame rate and format requirements
|
||||
|
||||
Provide your analysis as a JSON object. Be specific about any technical issues and reference the relevant platform or guideline.
|
||||
|
||||
RAG Status Guidelines:
|
||||
- **Green**: Fully suitable for the intended channel, all specs met
|
||||
- **Amber**: Minor technical adjustments needed for optimal display
|
||||
- **Red**: Significant technical issues that will impact display or accessibility
|
||||
|
||||
If the proof is nonsensical, not a marketing material, or cannot be analyzed, set analysisStatus to 'low_confidence'.
|
||||
"""
|
||||
|
||||
return await self.gemini.analyze_with_image(prompt, file_data, file_type)
|
||||
124
backend/app/agents/lead_agent.py
Normal file
124
backend/app/agents/lead_agent.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from app.models.schemas import SubReview, RagStatus, OverallStatus
|
||||
from app.services.gemini_service import GeminiService
|
||||
|
||||
|
||||
class LeadAgent:
|
||||
"""
|
||||
Lead Agent - synthesizes specialist agent reviews into final verdict.
|
||||
|
||||
Applies the decision logic:
|
||||
- Financial promotion detected → Requires Manual Legal Review
|
||||
- Any Error status → Analysis Error
|
||||
- Any Red status → Failed
|
||||
- Otherwise → Passed
|
||||
"""
|
||||
|
||||
name = "Lead Agent"
|
||||
|
||||
def __init__(self, gemini_service: GeminiService):
|
||||
"""
|
||||
Initialize the Lead Agent.
|
||||
|
||||
Args:
|
||||
gemini_service: Service for making Gemini API calls (for summary generation)
|
||||
"""
|
||||
self.gemini = gemini_service
|
||||
|
||||
async def synthesize(
|
||||
self,
|
||||
reviews: dict[str, SubReview],
|
||||
) -> tuple[OverallStatus, str, str | None]:
|
||||
"""
|
||||
Synthesize specialist reviews into final verdict and summary.
|
||||
|
||||
Args:
|
||||
reviews: Dictionary mapping agent names to their SubReview results
|
||||
|
||||
Returns:
|
||||
Tuple of (overall_status, summary, financial_promotion_reason)
|
||||
"""
|
||||
legal_review = reviews.get("Legal Agent")
|
||||
|
||||
# Check for financial promotion (from Legal Agent)
|
||||
is_financial_promotion = (
|
||||
legal_review is not None
|
||||
and legal_review.isFinancialPromotion is True
|
||||
)
|
||||
financial_promotion_reason = (
|
||||
legal_review.financialPromotionReason
|
||||
if legal_review and legal_review.financialPromotionReason
|
||||
else None
|
||||
)
|
||||
|
||||
# Build the prompt for Gemini to generate summary
|
||||
prompt = f"""
|
||||
You are a Lead Agent responsible for auditing a marketing proof. You have received feedback from specialist AI agents.
|
||||
Your task is to provide a final verdict and write a concise, professional summary to the user.
|
||||
|
||||
Here is the logic you must follow:
|
||||
1. The Legal Agent has determined if this is a financial promotion: {is_financial_promotion}.
|
||||
2. If it IS a financial promotion, the final verdict MUST be 'Requires Manual Legal Review'. Your summary should state this clearly, explain that a separate manual legal review is required, and then summarize any other issues found by the other agents.
|
||||
3. If it is NOT a financial promotion, follow the standard logic:
|
||||
a. If ANY specialist agent reports a 'ragStatus' of 'Error', the final verdict MUST be 'Analysis Error'.
|
||||
b. If ANY specialist agent reports a 'Red' status (and there are no 'Error' statuses), the final verdict MUST be 'Failed'.
|
||||
c. If there are NO 'Red' or 'Error' statuses, the final verdict is 'Passed'.
|
||||
|
||||
Your summary should:
|
||||
- For a 'Requires Manual Legal Review' verdict, start by stating this. Then, consolidate feedback from all agents, highlighting critical issues ('Red' items) or suggestions ('Amber' items).
|
||||
- For an 'Analysis Error' verdict, explain that the proof could not be reliably processed and has been logged for human review. Advise the user to try again with a revised proof.
|
||||
- For a 'Failed' status, highlight the critical 'Red' issues that must be addressed.
|
||||
- For a 'Passed' status, mention any 'Amber' areas for consideration, if they exist, while maintaining an encouraging tone.
|
||||
- Be professional, clear, and constructive.
|
||||
|
||||
Here are the specialist reviews:
|
||||
{self._format_reviews(reviews)}
|
||||
|
||||
Now, provide your final verdict and summary as a JSON object.
|
||||
"""
|
||||
|
||||
result = await self.gemini.generate_summary(prompt)
|
||||
|
||||
overall_status = OverallStatus(result.get("overallStatus", "Analysis Error"))
|
||||
summary = result.get("summary", "Unable to generate summary.")
|
||||
|
||||
# Override with financial promotion logic if applicable
|
||||
if is_financial_promotion:
|
||||
overall_status = OverallStatus.REQUIRES_MANUAL_LEGAL_REVIEW
|
||||
|
||||
return overall_status, summary, financial_promotion_reason
|
||||
|
||||
def _format_reviews(self, reviews: dict[str, SubReview]) -> str:
|
||||
"""Format reviews as a readable string for the prompt."""
|
||||
formatted = []
|
||||
for agent_name, review in reviews.items():
|
||||
formatted.append(f"""
|
||||
{agent_name}:
|
||||
RAG Status: {review.ragStatus}
|
||||
Feedback: {review.feedback}
|
||||
Issues: {review.issues if review.issues else 'None'}
|
||||
""")
|
||||
return "\n".join(formatted)
|
||||
|
||||
def determine_status_locally(self, reviews: dict[str, SubReview]) -> OverallStatus:
|
||||
"""
|
||||
Determine overall status using local logic (without Gemini).
|
||||
|
||||
This can be used as a fallback or for faster processing.
|
||||
"""
|
||||
legal_review = reviews.get("Legal Agent")
|
||||
|
||||
# Check for financial promotion
|
||||
if legal_review and legal_review.isFinancialPromotion:
|
||||
return OverallStatus.REQUIRES_MANUAL_LEGAL_REVIEW
|
||||
|
||||
# Check for Error status
|
||||
for review in reviews.values():
|
||||
if review.ragStatus == RagStatus.ERROR:
|
||||
return OverallStatus.ANALYSIS_ERROR
|
||||
|
||||
# Check for Red status
|
||||
for review in reviews.values():
|
||||
if review.ragStatus == RagStatus.RED:
|
||||
return OverallStatus.FAILED
|
||||
|
||||
return OverallStatus.PASSED
|
||||
35
backend/app/agents/legal_agent.py
Normal file
35
backend/app/agents/legal_agent.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import asyncio
|
||||
from app.agents.base_agent import BaseAgent
|
||||
from app.models.schemas import SubReview, RagStatus
|
||||
|
||||
|
||||
class LegalAgent(BaseAgent):
|
||||
"""
|
||||
Legal Agent - STUB implementation.
|
||||
|
||||
Returns mock Green status. Full legal review requires manual verification.
|
||||
"""
|
||||
|
||||
name = "Legal Agent"
|
||||
|
||||
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
|
||||
"""
|
||||
Stub implementation that returns mock Green status.
|
||||
|
||||
Args:
|
||||
file_data: Raw bytes of the file (not used in stub)
|
||||
file_type: MIME type of the file (not used in stub)
|
||||
|
||||
Returns:
|
||||
SubReview with Green status and stub notice
|
||||
"""
|
||||
# Simulate some processing time for realistic UX
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return SubReview(
|
||||
ragStatus=RagStatus.GREEN,
|
||||
feedback="[STUB] Legal compliance check passed. This is a placeholder response - the Legal Agent has not been implemented for this POC. Full legal review, including financial promotion detection and regulatory compliance, requires the complete implementation.",
|
||||
issues=[],
|
||||
isFinancialPromotion=False,
|
||||
financialPromotionReason=""
|
||||
)
|
||||
33
backend/app/agents/tone_agent.py
Normal file
33
backend/app/agents/tone_agent.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import asyncio
|
||||
from app.agents.base_agent import BaseAgent
|
||||
from app.models.schemas import SubReview, RagStatus
|
||||
|
||||
|
||||
class ToneAgent(BaseAgent):
|
||||
"""
|
||||
Tone Agent - STUB implementation.
|
||||
|
||||
Returns mock Green status. Full tone analysis requires implementation.
|
||||
"""
|
||||
|
||||
name = "Tone Agent"
|
||||
|
||||
async def analyze(self, file_data: bytes, file_type: str) -> SubReview:
|
||||
"""
|
||||
Stub implementation that returns mock Green status.
|
||||
|
||||
Args:
|
||||
file_data: Raw bytes of the file (not used in stub)
|
||||
file_type: MIME type of the file (not used in stub)
|
||||
|
||||
Returns:
|
||||
SubReview with Green status and stub notice
|
||||
"""
|
||||
# Simulate some processing time for realistic UX
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return SubReview(
|
||||
ragStatus=RagStatus.GREEN,
|
||||
feedback="[STUB] Tone of voice analysis passed. This is a placeholder response - the Tone Agent has not been implemented for this POC. The copy appears to demonstrate appropriate brand personality traits: Pioneering, Connected, Optimistic, and Professional. Full analysis of clarity, grammar, and brand voice alignment requires the complete implementation.",
|
||||
issues=[]
|
||||
)
|
||||
27
backend/app/config.py
Normal file
27
backend/app/config.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
|
||||
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:3000")
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
|
||||
# Reference docs path - defaults to ../reference_docs relative to backend/
|
||||
_default_ref_docs = Path(__file__).parent.parent.parent / "reference_docs"
|
||||
REFERENCE_DOCS_PATH: str = os.getenv("REFERENCE_DOCS_PATH", str(_default_ref_docs))
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate required settings are present."""
|
||||
if not self.GEMINI_API_KEY:
|
||||
raise ValueError("GEMINI_API_KEY environment variable is required")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
157
backend/app/main.py
Normal file
157
backend/app/main.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.websocket.manager import ConnectionManager
|
||||
from app.websocket.handlers import handle_analyze_message
|
||||
from app.services.gemini_service import GeminiService
|
||||
from app.services.reference_docs import ReferenceDocsService
|
||||
from app.services.analysis_service import AnalysisService
|
||||
|
||||
|
||||
# Global services - initialized at startup
|
||||
manager = ConnectionManager()
|
||||
analysis_service: AnalysisService | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
Initialize services on startup and cleanup on shutdown.
|
||||
|
||||
Loads reference documents and initializes the analysis service.
|
||||
"""
|
||||
global analysis_service
|
||||
|
||||
# Validate settings
|
||||
settings.validate()
|
||||
|
||||
# Initialize services
|
||||
print("Loading reference documents...")
|
||||
reference_docs = ReferenceDocsService(settings.REFERENCE_DOCS_PATH)
|
||||
|
||||
# Log document info
|
||||
doc_summary = reference_docs.get_context_summary()
|
||||
print(f" Brand documents: {len(doc_summary['brand_files'])} files ({doc_summary['brand_context_length']} chars)")
|
||||
print(f" Channel documents: {len(doc_summary['channel_files'])} files ({doc_summary['channel_context_length']} chars)")
|
||||
|
||||
print("Initializing Gemini service...")
|
||||
gemini_service = GeminiService(settings.GEMINI_API_KEY)
|
||||
|
||||
print("Initializing analysis service...")
|
||||
analysis_service = AnalysisService(gemini_service, reference_docs)
|
||||
|
||||
print("Backend ready!")
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup on shutdown (if needed)
|
||||
print("Shutting down...")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="ModComms Proof Review API",
|
||||
description="AI-powered proof review backend for Barclays marketing materials",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS middleware - allow frontend to connect
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS.split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "service": "modcomms-backend"}
|
||||
|
||||
|
||||
@app.get("/info")
|
||||
async def info():
|
||||
"""Get backend information."""
|
||||
if analysis_service:
|
||||
ref_docs = analysis_service.reference_docs
|
||||
doc_summary = ref_docs.get_context_summary()
|
||||
return {
|
||||
"status": "ready",
|
||||
"agents": ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"],
|
||||
"reference_docs": doc_summary,
|
||||
}
|
||||
return {"status": "initializing"}
|
||||
|
||||
|
||||
@app.websocket("/ws/analyze")
|
||||
async def websocket_analyze(websocket: WebSocket):
|
||||
"""
|
||||
WebSocket endpoint for proof analysis with real-time updates.
|
||||
|
||||
Protocol:
|
||||
- Client sends: {"type": "analyze", "file_data": "<base64>", "file_type": "image/png", "is_wip": false}
|
||||
- Server sends: {"type": "agent_started", "agent_name": "..."}
|
||||
- Server sends: {"type": "agent_completed", "agent_name": "...", "review": {...}}
|
||||
- Server sends: {"type": "complete", "result": {...}}
|
||||
- On error: {"type": "error", "message": "..."}
|
||||
"""
|
||||
client_id = str(uuid.uuid4())
|
||||
logger.info(f"[MAIN] WebSocket connection established - client_id: {client_id}")
|
||||
await manager.connect(websocket, client_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for a message from the client
|
||||
data = await websocket.receive_json()
|
||||
logger.info(f"[MAIN] Received message from client {client_id} - type: {data.get('type')}")
|
||||
|
||||
if data.get("type") == "analyze":
|
||||
if analysis_service is None:
|
||||
logger.error("[MAIN] Analysis service not ready")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "error",
|
||||
"message": "Backend not ready. Please wait for initialization."
|
||||
})
|
||||
continue
|
||||
|
||||
# Handle the analysis request
|
||||
await handle_analyze_message(
|
||||
websocket=websocket,
|
||||
client_id=client_id,
|
||||
data=data,
|
||||
manager=manager,
|
||||
analysis_service=analysis_service,
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[MAIN] Unknown message type: {data.get('type')}")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "error",
|
||||
"message": f"Unknown message type: {data.get('type')}"
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"[MAIN] Client {client_id} disconnected")
|
||||
manager.disconnect(client_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[MAIN] Error for client {client_id}: {str(e)}")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "error",
|
||||
"message": str(e)
|
||||
})
|
||||
manager.disconnect(client_id)
|
||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .schemas import RagStatus, OverallStatus, SubReview, AgentReview
|
||||
96
backend/app/models/schemas.py
Normal file
96
backend/app/models/schemas.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from enum import Enum
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RagStatus(str, Enum):
|
||||
"""RAG status for agent reviews."""
|
||||
RED = "Red"
|
||||
AMBER = "Amber"
|
||||
GREEN = "Green"
|
||||
ERROR = "Error"
|
||||
|
||||
|
||||
class OverallStatus(str, Enum):
|
||||
"""Overall status for the proof review."""
|
||||
PASSED = "Passed"
|
||||
FAILED = "Failed"
|
||||
ANALYSIS_ERROR = "Analysis Error"
|
||||
REQUIRES_MANUAL_LEGAL_REVIEW = "Requires Manual Legal Review"
|
||||
|
||||
|
||||
class SubReview(BaseModel):
|
||||
"""Individual agent review result."""
|
||||
ragStatus: RagStatus
|
||||
feedback: str
|
||||
issues: list[str]
|
||||
isFinancialPromotion: Optional[bool] = None
|
||||
financialPromotionReason: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class AgentReview(BaseModel):
|
||||
"""Complete review from all agents."""
|
||||
legalAgentReview: SubReview
|
||||
brandAgentReview: SubReview
|
||||
toneAgentReview: SubReview
|
||||
channelAgentReview: SubReview
|
||||
leadAgentSummary: str
|
||||
overallStatus: OverallStatus
|
||||
financialPromotionReason: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
# WebSocket message types
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""Request to analyze a proof via WebSocket."""
|
||||
type: str # Should be "analyze"
|
||||
file_data: str # Base64 encoded file
|
||||
file_type: str # MIME type
|
||||
is_wip: bool = False
|
||||
|
||||
|
||||
class AgentStartedMessage(BaseModel):
|
||||
"""Message sent when an agent starts processing."""
|
||||
type: str = "agent_started"
|
||||
agent_name: str
|
||||
|
||||
|
||||
class AgentCompletedMessage(BaseModel):
|
||||
"""Message sent when an agent completes."""
|
||||
type: str = "agent_completed"
|
||||
agent_name: str
|
||||
review: SubReview
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class SummaryMessage(BaseModel):
|
||||
"""Message sent when summary is ready."""
|
||||
type: str = "summary"
|
||||
lead_agent_summary: str
|
||||
overall_status: OverallStatus
|
||||
financial_promotion_reason: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class CompleteMessage(BaseModel):
|
||||
"""Message sent when analysis is complete."""
|
||||
type: str = "complete"
|
||||
result: AgentReview
|
||||
|
||||
class Config:
|
||||
use_enum_values = True
|
||||
|
||||
|
||||
class ErrorMessage(BaseModel):
|
||||
"""Message sent when an error occurs."""
|
||||
type: str = "error"
|
||||
message: str
|
||||
3
backend/app/services/__init__.py
Normal file
3
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .gemini_service import GeminiService
|
||||
from .reference_docs import ReferenceDocsService
|
||||
from .analysis_service import AnalysisService
|
||||
115
backend/app/services/analysis_service.py
Normal file
115
backend/app/services/analysis_service.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import logging
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
from app.models.schemas import SubReview, AgentReview, OverallStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.agents.brand_agent import BrandAgent
|
||||
from app.agents.channel_agent import ChannelAgent
|
||||
from app.agents.legal_agent import LegalAgent
|
||||
from app.agents.tone_agent import ToneAgent
|
||||
from app.agents.lead_agent import LeadAgent
|
||||
from app.services.gemini_service import GeminiService
|
||||
from app.services.reference_docs import ReferenceDocsService
|
||||
|
||||
|
||||
# Type alias for the callback function
|
||||
AgentCallback = Callable[[str, SubReview | None], Awaitable[None]]
|
||||
|
||||
|
||||
class AnalysisService:
|
||||
"""
|
||||
Orchestrates the multi-agent proof analysis.
|
||||
|
||||
Runs agents sequentially and provides callbacks for real-time updates.
|
||||
"""
|
||||
|
||||
# Agent execution order
|
||||
AGENT_ORDER = ["Legal Agent", "Brand Agent", "Tone Agent", "Channel Agent"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
gemini_service: GeminiService,
|
||||
reference_docs: ReferenceDocsService,
|
||||
):
|
||||
"""
|
||||
Initialize the analysis service with all required agents.
|
||||
|
||||
Args:
|
||||
gemini_service: Service for Gemini API calls
|
||||
reference_docs: Service for loading reference documents
|
||||
"""
|
||||
self.gemini_service = gemini_service
|
||||
self.reference_docs = reference_docs
|
||||
|
||||
# Initialize agents
|
||||
self.agents = {
|
||||
"Legal Agent": LegalAgent(),
|
||||
"Brand Agent": BrandAgent(gemini_service, reference_docs),
|
||||
"Tone Agent": ToneAgent(),
|
||||
"Channel Agent": ChannelAgent(gemini_service, reference_docs),
|
||||
}
|
||||
self.lead_agent = LeadAgent(gemini_service)
|
||||
|
||||
async def analyze_proof(
|
||||
self,
|
||||
file_data: bytes,
|
||||
file_type: str,
|
||||
on_agent_update: AgentCallback | None = None,
|
||||
is_wip: bool = False,
|
||||
) -> AgentReview:
|
||||
"""
|
||||
Analyze a proof using all agents sequentially.
|
||||
|
||||
Args:
|
||||
file_data: Raw bytes of the file to analyze
|
||||
file_type: MIME type of the file
|
||||
on_agent_update: Optional callback for real-time agent updates.
|
||||
Called with (agent_name, None) when agent starts,
|
||||
and (agent_name, review) when agent completes.
|
||||
is_wip: Whether this is a work-in-progress analysis
|
||||
|
||||
Returns:
|
||||
Complete AgentReview with all agent results and overall verdict
|
||||
"""
|
||||
logger.info(f"[ANALYSIS] Starting proof analysis - file_type: {file_type}, file_size: {len(file_data)} bytes, is_wip: {is_wip}")
|
||||
reviews: dict[str, SubReview] = {}
|
||||
|
||||
# Run each agent sequentially
|
||||
for agent_name in self.AGENT_ORDER:
|
||||
agent = self.agents[agent_name]
|
||||
|
||||
logger.info(f"[ANALYSIS] Starting agent: {agent_name}")
|
||||
|
||||
# Notify that agent is starting
|
||||
if on_agent_update:
|
||||
await on_agent_update(agent_name, None)
|
||||
|
||||
# Run the agent
|
||||
review = await agent.analyze(file_data, file_type)
|
||||
reviews[agent_name] = review
|
||||
|
||||
logger.info(f"[ANALYSIS] Agent completed: {agent_name} - ragStatus: {review.ragStatus}")
|
||||
|
||||
# Notify that agent completed
|
||||
if on_agent_update:
|
||||
await on_agent_update(agent_name, review)
|
||||
|
||||
# Get lead agent synthesis
|
||||
logger.info("[ANALYSIS] Starting lead agent synthesis")
|
||||
if on_agent_update:
|
||||
await on_agent_update("Summary", None)
|
||||
|
||||
overall_status, summary, financial_promotion_reason = await self.lead_agent.synthesize(reviews)
|
||||
logger.info(f"[ANALYSIS] Analysis complete - overallStatus: {overall_status}")
|
||||
|
||||
# Build the complete AgentReview
|
||||
return AgentReview(
|
||||
legalAgentReview=reviews["Legal Agent"],
|
||||
brandAgentReview=reviews["Brand Agent"],
|
||||
toneAgentReview=reviews["Tone Agent"],
|
||||
channelAgentReview=reviews["Channel Agent"],
|
||||
leadAgentSummary=summary,
|
||||
overallStatus=overall_status,
|
||||
financialPromotionReason=financial_promotion_reason,
|
||||
)
|
||||
173
backend/app/services/gemini_service.py
Normal file
173
backend/app/services/gemini_service.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import json
|
||||
import logging
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
from app.models.schemas import SubReview, RagStatus
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeminiService:
|
||||
"""Service wrapper for Google Gemini API calls."""
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
"""
|
||||
Initialize the Gemini service.
|
||||
|
||||
Args:
|
||||
api_key: Google Gemini API key
|
||||
"""
|
||||
self.client = genai.Client(api_key=api_key)
|
||||
self.model = "gemini-2.5-flash"
|
||||
|
||||
async def analyze_with_image(
|
||||
self,
|
||||
prompt: str,
|
||||
file_data: bytes,
|
||||
file_type: str,
|
||||
) -> SubReview:
|
||||
"""
|
||||
Analyze an image/file with Gemini and return a structured SubReview.
|
||||
|
||||
Args:
|
||||
prompt: The analysis prompt including reference doc context
|
||||
file_data: Raw bytes of the file
|
||||
file_type: MIME type of the file (e.g., "image/png")
|
||||
|
||||
Returns:
|
||||
SubReview with ragStatus, feedback, and issues
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[GEMINI API] Starting image analysis - file_type: {file_type}, file_size: {len(file_data)} bytes")
|
||||
|
||||
# Create inline data part for the file
|
||||
file_part = types.Part.from_bytes(
|
||||
data=file_data,
|
||||
mime_type=file_type
|
||||
)
|
||||
|
||||
# Define the response schema for structured output
|
||||
response_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"analysisStatus": {
|
||||
"type": "string",
|
||||
"enum": ["success", "low_confidence"],
|
||||
"description": "Set to 'low_confidence' if the proof is nonsensical, completely irrelevant to marketing, or otherwise impossible to analyze. Otherwise, set to 'success'."
|
||||
},
|
||||
"ragStatus": {
|
||||
"type": "string",
|
||||
"enum": ["Red", "Amber", "Green"],
|
||||
"description": "A RAG status. Red: Issues that must be resolved. Amber: Issues that should be addressed. Green: No issues found."
|
||||
},
|
||||
"feedback": {
|
||||
"type": "string",
|
||||
"description": "Constructive, professional feedback explaining the RAG status and highlighting both positive aspects and areas for improvement."
|
||||
},
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "A list of specific, actionable issues found. If no issues, return an empty array."
|
||||
}
|
||||
},
|
||||
"required": ["analysisStatus", "ragStatus", "feedback", "issues"]
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
logger.info(f"[GEMINI API] Calling Gemini model: {self.model}")
|
||||
response = await self.client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=[file_part, prompt],
|
||||
config=types.GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_schema
|
||||
)
|
||||
)
|
||||
logger.info(f"[GEMINI API] Response received from Gemini")
|
||||
|
||||
# Parse the JSON response
|
||||
json_text = response.text.strip()
|
||||
parsed_result = json.loads(json_text)
|
||||
logger.info(f"[GEMINI API] Parsed result - ragStatus: {parsed_result.get('ragStatus')}, analysisStatus: {parsed_result.get('analysisStatus')}")
|
||||
|
||||
# Handle low confidence analysis
|
||||
if parsed_result.get("analysisStatus") == "low_confidence":
|
||||
return SubReview(
|
||||
ragStatus=RagStatus.ERROR,
|
||||
feedback="The agent could not analyze this proof with high confidence. This may be because the content is irrelevant, nonsensical, or too far outside of expected marketing materials.",
|
||||
issues=[]
|
||||
)
|
||||
|
||||
# Return successful analysis
|
||||
return SubReview(
|
||||
ragStatus=RagStatus(parsed_result["ragStatus"]),
|
||||
feedback=parsed_result["feedback"],
|
||||
issues=parsed_result["issues"]
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[GEMINI API] JSON parse error: {str(e)}")
|
||||
return SubReview(
|
||||
ragStatus=RagStatus.ERROR,
|
||||
feedback=f"Failed to parse AI response as JSON: {str(e)}",
|
||||
issues=[]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[GEMINI API] Error during analysis: {str(e)}")
|
||||
return SubReview(
|
||||
ragStatus=RagStatus.ERROR,
|
||||
feedback=f"An error occurred during analysis: {str(e)}",
|
||||
issues=[]
|
||||
)
|
||||
|
||||
async def generate_summary(
|
||||
self,
|
||||
prompt: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Generate a text summary (for lead agent).
|
||||
|
||||
Args:
|
||||
prompt: The prompt for generating the summary
|
||||
|
||||
Returns:
|
||||
Parsed JSON response with summary and overall_status
|
||||
"""
|
||||
try:
|
||||
logger.info("[GEMINI API] Generating lead agent summary")
|
||||
response_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"overallStatus": {
|
||||
"type": "string",
|
||||
"enum": ["Passed", "Failed", "Analysis Error", "Requires Manual Legal Review"],
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "A concise, professional summary explaining the overall status, based on the specialist reviews."
|
||||
}
|
||||
},
|
||||
"required": ["overallStatus", "summary"]
|
||||
}
|
||||
|
||||
response = await self.client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_schema
|
||||
)
|
||||
)
|
||||
|
||||
result = json.loads(response.text.strip())
|
||||
logger.info(f"[GEMINI API] Summary generated - overallStatus: {result.get('overallStatus')}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[GEMINI API] Error generating summary: {str(e)}")
|
||||
return {
|
||||
"overallStatus": "Analysis Error",
|
||||
"summary": f"An error occurred while generating the summary: {str(e)}"
|
||||
}
|
||||
77
backend/app/services/reference_docs.py
Normal file
77
backend/app/services/reference_docs.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from pathlib import Path
|
||||
|
||||
|
||||
class ReferenceDocsService:
|
||||
"""Service to load and provide reference documents for agents."""
|
||||
|
||||
def __init__(self, base_path: str | None = None):
|
||||
"""
|
||||
Initialize the reference docs service.
|
||||
|
||||
Args:
|
||||
base_path: Path to the reference_docs directory.
|
||||
Defaults to ../reference_docs relative to backend/
|
||||
"""
|
||||
if base_path is None:
|
||||
# Default to reference_docs at project root (sibling to backend/)
|
||||
base_path = Path(__file__).parent.parent.parent.parent / "reference_docs"
|
||||
self.base_path = Path(base_path)
|
||||
|
||||
# Cache loaded documents
|
||||
self._brand_context: str | None = None
|
||||
self._channel_context: str | None = None
|
||||
|
||||
def get_brand_context(self) -> str:
|
||||
"""Load and return all brand guideline documents as a single context string."""
|
||||
if self._brand_context is None:
|
||||
brand_path = self.base_path / "brand"
|
||||
self._brand_context = self._load_all_markdown_files(brand_path)
|
||||
return self._brand_context
|
||||
|
||||
def get_channel_context(self) -> str:
|
||||
"""Load and return all channel guideline documents as a single context string."""
|
||||
if self._channel_context is None:
|
||||
channel_path = self.base_path / "channel"
|
||||
self._channel_context = self._load_all_markdown_files(channel_path)
|
||||
return self._channel_context
|
||||
|
||||
def _load_all_markdown_files(self, directory: Path) -> str:
|
||||
"""
|
||||
Load all .md files from a directory and concatenate them.
|
||||
|
||||
Args:
|
||||
directory: Path to the directory containing markdown files
|
||||
|
||||
Returns:
|
||||
Concatenated content of all markdown files with section headers
|
||||
"""
|
||||
contents = []
|
||||
if directory.exists():
|
||||
# Sort files for consistent ordering
|
||||
for md_file in sorted(directory.glob("*.md")):
|
||||
try:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
# Add file name as section header
|
||||
contents.append(f"## {md_file.stem}\n\n{content}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read {md_file}: {e}")
|
||||
|
||||
if not contents:
|
||||
return "No reference documents found."
|
||||
|
||||
return "\n\n---\n\n".join(contents)
|
||||
|
||||
def get_context_summary(self) -> dict:
|
||||
"""Return summary info about loaded documents."""
|
||||
brand_path = self.base_path / "brand"
|
||||
channel_path = self.base_path / "channel"
|
||||
|
||||
brand_files = list(brand_path.glob("*.md")) if brand_path.exists() else []
|
||||
channel_files = list(channel_path.glob("*.md")) if channel_path.exists() else []
|
||||
|
||||
return {
|
||||
"brand_files": [f.name for f in brand_files],
|
||||
"channel_files": [f.name for f in channel_files],
|
||||
"brand_context_length": len(self.get_brand_context()),
|
||||
"channel_context_length": len(self.get_channel_context()),
|
||||
}
|
||||
2
backend/app/websocket/__init__.py
Normal file
2
backend/app/websocket/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .manager import ConnectionManager
|
||||
from .handlers import handle_analyze_message
|
||||
132
backend/app/websocket/handlers.py
Normal file
132
backend/app/websocket/handlers.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import base64
|
||||
import logging
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.websocket.manager import ConnectionManager
|
||||
from app.services.analysis_service import AnalysisService
|
||||
from app.models.schemas import SubReview
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_analyze_message(
|
||||
websocket: WebSocket,
|
||||
client_id: str,
|
||||
data: dict,
|
||||
manager: ConnectionManager,
|
||||
analysis_service: AnalysisService,
|
||||
) -> None:
|
||||
"""
|
||||
Handle an 'analyze' message from the client.
|
||||
|
||||
Runs the proof analysis and sends real-time updates via WebSocket.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection
|
||||
client_id: Unique client identifier
|
||||
data: The message data containing file_data, file_type, is_wip
|
||||
manager: Connection manager for sending messages
|
||||
analysis_service: Service to run the analysis
|
||||
"""
|
||||
try:
|
||||
logger.info(f"[WEBSOCKET] Received analyze request from client: {client_id}")
|
||||
|
||||
# Extract and decode the file data
|
||||
file_data_b64 = data.get("file_data", "")
|
||||
file_type = data.get("file_type", "image/png")
|
||||
is_wip = data.get("is_wip", False)
|
||||
|
||||
logger.info(f"[WEBSOCKET] File type: {file_type}, is_wip: {is_wip}, base64 length: {len(file_data_b64)}")
|
||||
|
||||
# Decode base64 file data
|
||||
try:
|
||||
file_data = base64.b64decode(file_data_b64)
|
||||
logger.info(f"[WEBSOCKET] Decoded file size: {len(file_data)} bytes")
|
||||
except Exception as e:
|
||||
logger.error(f"[WEBSOCKET] Failed to decode file data: {str(e)}")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "error",
|
||||
"message": f"Failed to decode file data: {str(e)}"
|
||||
})
|
||||
return
|
||||
|
||||
# Create callback for real-time updates
|
||||
async def on_agent_update(agent_name: str, review: SubReview | None) -> None:
|
||||
if not manager.is_connected(client_id):
|
||||
logger.warning(f"[WEBSOCKET] Client {client_id} disconnected, skipping update")
|
||||
return
|
||||
|
||||
if review is None:
|
||||
# Agent is starting
|
||||
logger.info(f"[WEBSOCKET] Sending agent_started: {agent_name}")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "agent_started",
|
||||
"agent_name": agent_name
|
||||
})
|
||||
else:
|
||||
# Agent completed
|
||||
logger.info(f"[WEBSOCKET] Sending agent_completed: {agent_name} - ragStatus: {review.ragStatus}")
|
||||
await manager.send_message(client_id, {
|
||||
"type": "agent_completed",
|
||||
"agent_name": agent_name,
|
||||
"review": {
|
||||
"ragStatus": review.ragStatus,
|
||||
"feedback": review.feedback,
|
||||
"issues": review.issues,
|
||||
"isFinancialPromotion": review.isFinancialPromotion,
|
||||
"financialPromotionReason": review.financialPromotionReason,
|
||||
}
|
||||
})
|
||||
|
||||
# Run the analysis
|
||||
logger.info("[WEBSOCKET] Starting analysis...")
|
||||
result = await analysis_service.analyze_proof(
|
||||
file_data=file_data,
|
||||
file_type=file_type,
|
||||
on_agent_update=on_agent_update,
|
||||
is_wip=is_wip,
|
||||
)
|
||||
|
||||
# Send the complete result
|
||||
logger.info(f"[WEBSOCKET] Analysis complete, sending result - overallStatus: {result.overallStatus}")
|
||||
if manager.is_connected(client_id):
|
||||
await manager.send_message(client_id, {
|
||||
"type": "complete",
|
||||
"result": {
|
||||
"legalAgentReview": {
|
||||
"ragStatus": result.legalAgentReview.ragStatus,
|
||||
"feedback": result.legalAgentReview.feedback,
|
||||
"issues": result.legalAgentReview.issues,
|
||||
"isFinancialPromotion": result.legalAgentReview.isFinancialPromotion,
|
||||
"financialPromotionReason": result.legalAgentReview.financialPromotionReason,
|
||||
},
|
||||
"brandAgentReview": {
|
||||
"ragStatus": result.brandAgentReview.ragStatus,
|
||||
"feedback": result.brandAgentReview.feedback,
|
||||
"issues": result.brandAgentReview.issues,
|
||||
},
|
||||
"toneAgentReview": {
|
||||
"ragStatus": result.toneAgentReview.ragStatus,
|
||||
"feedback": result.toneAgentReview.feedback,
|
||||
"issues": result.toneAgentReview.issues,
|
||||
},
|
||||
"channelAgentReview": {
|
||||
"ragStatus": result.channelAgentReview.ragStatus,
|
||||
"feedback": result.channelAgentReview.feedback,
|
||||
"issues": result.channelAgentReview.issues,
|
||||
},
|
||||
"leadAgentSummary": result.leadAgentSummary,
|
||||
"overallStatus": result.overallStatus,
|
||||
"financialPromotionReason": result.financialPromotionReason,
|
||||
}
|
||||
})
|
||||
logger.info(f"[WEBSOCKET] Result sent to client: {client_id}")
|
||||
|
||||
except Exception as e:
|
||||
# Send error message
|
||||
logger.error(f"[WEBSOCKET] Analysis failed for client {client_id}: {str(e)}")
|
||||
if manager.is_connected(client_id):
|
||||
await manager.send_message(client_id, {
|
||||
"type": "error",
|
||||
"message": f"Analysis failed: {str(e)}"
|
||||
})
|
||||
49
backend/app/websocket/manager.py
Normal file
49
backend/app/websocket/manager.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections for real-time updates."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the connection manager."""
|
||||
self.active_connections: dict[str, WebSocket] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, client_id: str) -> None:
|
||||
"""
|
||||
Accept a new WebSocket connection.
|
||||
|
||||
Args:
|
||||
websocket: The WebSocket connection
|
||||
client_id: Unique identifier for this client
|
||||
"""
|
||||
await websocket.accept()
|
||||
self.active_connections[client_id] = websocket
|
||||
|
||||
def disconnect(self, client_id: str) -> None:
|
||||
"""
|
||||
Remove a client connection.
|
||||
|
||||
Args:
|
||||
client_id: The client to disconnect
|
||||
"""
|
||||
if client_id in self.active_connections:
|
||||
del self.active_connections[client_id]
|
||||
|
||||
async def send_message(self, client_id: str, message: dict) -> None:
|
||||
"""
|
||||
Send a JSON message to a specific client.
|
||||
|
||||
Args:
|
||||
client_id: The target client
|
||||
message: Dictionary to send as JSON
|
||||
"""
|
||||
if client_id in self.active_connections:
|
||||
try:
|
||||
await self.active_connections[client_id].send_json(message)
|
||||
except Exception:
|
||||
# Client may have disconnected
|
||||
self.disconnect(client_id)
|
||||
|
||||
def is_connected(self, client_id: str) -> bool:
|
||||
"""Check if a client is still connected."""
|
||||
return client_id in self.active_connections
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
python-dotenv>=1.0.0
|
||||
google-genai>=1.5.4
|
||||
pydantic>=2.5.0
|
||||
python-multipart>=0.0.9
|
||||
aiofiles>=23.2.1
|
||||
websockets>=12.0
|
||||
813
frontend/App.tsx
Normal file
813
frontend/App.tsx
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Hero } from './components/Hero';
|
||||
import { analyzeProof } from './services/geminiService';
|
||||
import type { AgentReview, AgentName, FlaggedItem, ResolvedItem, ErrorItem } from './types';
|
||||
import { AGENT_NAMES } from './constants';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { ChecksOverview } from './components/ChecksOverview';
|
||||
import { Analytics } from './components/Analytics';
|
||||
import { Profile } from './components/Profile';
|
||||
import { CopyGenAI } from './components/CopyGenAI';
|
||||
import { Settings } from './components/Settings';
|
||||
import { Campaigns, initialCampaigns, initialCampaignProofs } from './components/Campaigns';
|
||||
import { Auditing } from './components/Auditing';
|
||||
import { Login } from './components/Login';
|
||||
import { WIPReviewer } from './components/WIPReviewer';
|
||||
|
||||
type View = 'Home' | 'Analytics' | 'Campaigns' | 'WIP Reviewer' | 'CopyGenAI' | 'Settings' | 'Profile' | 'Auditing';
|
||||
|
||||
export interface DropdownOptions {
|
||||
campaigns: string[];
|
||||
// Hierarchy: Channel -> SubChannel -> ProofType[]
|
||||
channels: Record<string, Record<string, string[]>>;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [currentView, setCurrentView] = useState<View>('Home');
|
||||
const [selectedCampaign, setSelectedCampaign] = useState<string | null>(null);
|
||||
const [selectedProof, setSelectedProof] = useState<any | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [dropdownOptions, setDropdownOptions] = useState<DropdownOptions>(() => {
|
||||
try {
|
||||
const storedOptions = localStorage.getItem('barclays_modcomms_dropdown_options_v3');
|
||||
if (storedOptions) {
|
||||
return JSON.parse(storedOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading dropdown options from localStorage', error);
|
||||
}
|
||||
return {
|
||||
campaigns: ["Barclays Q4 Social"],
|
||||
channels: {
|
||||
"Social": {
|
||||
"Meta": ["In-feed 1x1", "In-feed 4x5", "Reels static 9x16", "Stories Static 9x16", "In-feed 1x1 video", "9x16 reels", "9x16 stories"]
|
||||
},
|
||||
"YouTube - (online video)": {
|
||||
"Video": ["1x1", "16x9"]
|
||||
},
|
||||
"Google - Performance Max": {
|
||||
"Video": ["1x1", "9x16", "16x9"],
|
||||
"Static image": ["1080x1080", "4x5 (1080x1350)", "1.91x1 (1200x628)", "Logo 1x1 (1080x1080)"]
|
||||
},
|
||||
"Display": {
|
||||
"Banner": ["300x600", "160x600", "970x250", "300x250"]
|
||||
},
|
||||
".co.uk Banner": {
|
||||
"Web Banner Design + Static": ["720x540", "1316x740"]
|
||||
},
|
||||
"Ad Copy": {
|
||||
"Copy Document": ["Text"]
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem('barclays_modcomms_dropdown_options_v3', JSON.stringify(dropdownOptions));
|
||||
} catch (error) {
|
||||
console.error('Error saving dropdown options to localStorage', error);
|
||||
}
|
||||
}, [dropdownOptions]);
|
||||
|
||||
|
||||
const [campaigns, setCampaigns] = useState(() => {
|
||||
try {
|
||||
const storedCampaigns = localStorage.getItem('barclays_modcomms_campaigns_v3');
|
||||
return storedCampaigns ? JSON.parse(storedCampaigns) : initialCampaigns;
|
||||
} catch (error) {
|
||||
console.error('Error reading campaigns from localStorage', error);
|
||||
return initialCampaigns;
|
||||
}
|
||||
});
|
||||
|
||||
const [campaignProofs, setCampaignProofs] = useState(() => {
|
||||
try {
|
||||
const storedProofs = localStorage.getItem('barclays_modcomms_campaign_proofs_v3');
|
||||
return storedProofs ? JSON.parse(storedProofs) : initialCampaignProofs;
|
||||
} catch (error) {
|
||||
console.error('Error reading campaign proofs from localStorage', error);
|
||||
return initialCampaignProofs;
|
||||
}
|
||||
});
|
||||
|
||||
const [flaggedItems, setFlaggedItems] = useState<FlaggedItem[]>(() => {
|
||||
try {
|
||||
const storedFlags = localStorage.getItem('barclays_modcomms_flagged_items_v3');
|
||||
return storedFlags ? JSON.parse(storedFlags) : [];
|
||||
} catch (error) {
|
||||
console.error('Error reading flagged items from localStorage', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const [resolvedItems, setResolvedItems] = useState<ResolvedItem[]>(() => {
|
||||
try {
|
||||
const storedResolutions = localStorage.getItem('barclays_modcomms_resolved_items_v3');
|
||||
return storedResolutions ? JSON.parse(storedResolutions) : [];
|
||||
} catch (error) {
|
||||
console.error('Error reading resolved items from localStorage', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const [errorItems, setErrorItems] = useState<ErrorItem[]>(() => {
|
||||
try {
|
||||
const storedErrors = localStorage.getItem('barclays_modcomms_error_items_v3');
|
||||
return storedErrors ? JSON.parse(storedErrors) : [];
|
||||
} catch (error) {
|
||||
console.error('Error reading error items from localStorage', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('barclays_modcomms_campaigns_v3')) {
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(initialCampaigns));
|
||||
}
|
||||
if (!localStorage.getItem('barclays_modcomms_campaign_proofs_v3')) {
|
||||
localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(initialCampaignProofs));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving initial data to localStorage', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Keep selectedProof in sync with the master list in campaignProofs.
|
||||
// This ensures that when a new version is added, the detail view refreshes.
|
||||
if (selectedCampaign && selectedProof && campaignProofs[selectedCampaign]) {
|
||||
const currentCampaignProofs = campaignProofs[selectedCampaign];
|
||||
const freshProof = currentCampaignProofs.find(a => !a.tempId && a.proofName === selectedProof.proofName);
|
||||
|
||||
// A simple stringify check to prevent re-renders if the object is the same.
|
||||
if (freshProof && JSON.stringify(freshProof) !== JSON.stringify(selectedProof)) {
|
||||
setSelectedProof(freshProof);
|
||||
}
|
||||
}
|
||||
}, [campaignProofs, selectedCampaign, selectedProof]);
|
||||
|
||||
// New function to handle saving campaign proofs to avoid QuotaExceededError
|
||||
const saveCampaignProofs = (proofs: any) => {
|
||||
try {
|
||||
// Deep clone to avoid mutating the state object used by React
|
||||
const proofsToSave = JSON.parse(JSON.stringify(proofs));
|
||||
|
||||
// Iterate over all campaigns and their proofs
|
||||
for (const campaignName in proofsToSave) {
|
||||
if (Object.prototype.hasOwnProperty.call(proofsToSave, campaignName)) {
|
||||
proofsToSave[campaignName].forEach((proof: any) => {
|
||||
// For temporary placeholder proofs, remove the file object and progress
|
||||
// as it cannot be stringified and is not needed for persistence.
|
||||
if (proof.tempId) {
|
||||
if ('file' in proof) delete proof.file;
|
||||
if ('analysisProgress' in proof) delete proof.analysisProgress;
|
||||
}
|
||||
|
||||
// For versioned proofs, remove large non-SVG preview URLs
|
||||
if (proof.versions) {
|
||||
proof.versions.forEach((version: any) => {
|
||||
if (version.proofPreviewUrl && !version.proofPreviewUrl.startsWith('data:image/svg+xml')) {
|
||||
delete version.proofPreviewUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('barclays_modcomms_campaign_proofs_v3', JSON.stringify(proofsToSave));
|
||||
} catch (error) {
|
||||
if ((error as DOMException).name === 'QuotaExceededError') {
|
||||
console.error('LocalStorage quota exceeded. Could not save campaign proofs. The app might not persist data correctly across sessions until storage is cleared.', error);
|
||||
setError('Could not save campaign changes, storage is full.');
|
||||
} else {
|
||||
console.error('Error saving campaign proofs to localStorage', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewCampaign = (campaignData: { name: string; workfrontId: string; clientLead: string; brandGuidelines: string; }) => {
|
||||
const newCampaign = {
|
||||
...campaignData,
|
||||
agency: "OLIVER Agency",
|
||||
agencyLead: "Steve O'Donoghue",
|
||||
proofs: 0,
|
||||
status: 'In Progress',
|
||||
lastModified: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
const updatedCampaigns = [...campaigns, newCampaign];
|
||||
const updatedCampaignProofs = { ...campaignProofs, [newCampaign.name]: [] };
|
||||
|
||||
setCampaigns(updatedCampaigns);
|
||||
setCampaignProofs(updatedCampaignProofs);
|
||||
|
||||
try {
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
|
||||
saveCampaignProofs(updatedCampaignProofs);
|
||||
} catch (error) {
|
||||
console.error('Error saving new campaign to localStorage', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fileToDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddNewError = (errorData: Omit<ErrorItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
|
||||
const newError: ErrorItem = {
|
||||
...errorData,
|
||||
id: `err_${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
submitter: "Steve O'Donoghue", // Hardcoded for prototype
|
||||
submitAgency: "OLIVER Agency", // Hardcoded for prototype
|
||||
};
|
||||
|
||||
setErrorItems(prevItems => {
|
||||
const updatedErrors = [newError, ...prevItems];
|
||||
try {
|
||||
localStorage.setItem('barclays_modcomms_error_items_v3', JSON.stringify(updatedErrors));
|
||||
} catch (error) {
|
||||
console.error('Error saving error items to localStorage', error);
|
||||
setError('Could not save the analysis error log due to storage limitations.');
|
||||
}
|
||||
return updatedErrors;
|
||||
});
|
||||
};
|
||||
|
||||
const handleProofUploadForCampaign = async (
|
||||
campaignName: string,
|
||||
file: File,
|
||||
proofName: string,
|
||||
channel: string,
|
||||
subChannel: string,
|
||||
proofType?: string
|
||||
) => {
|
||||
setError(null);
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const newProofPlaceholder = {
|
||||
tempId,
|
||||
proofName,
|
||||
channel,
|
||||
subChannel,
|
||||
proofType,
|
||||
status: 'analyzing',
|
||||
analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 },
|
||||
file,
|
||||
versions: [],
|
||||
};
|
||||
|
||||
setCampaignProofs(prevProofs => ({
|
||||
...prevProofs,
|
||||
[campaignName]: [newProofPlaceholder, ...(prevProofs[campaignName] || [])]
|
||||
}));
|
||||
|
||||
const handleAgentUpdate = (agentName: AgentName | 'Summary') => {
|
||||
setCampaignProofs(prevProofs => {
|
||||
const currentProofs = prevProofs[campaignName] || [];
|
||||
const updatedProofs = currentProofs.map(proof => {
|
||||
if (proof.tempId === tempId && proof.status === 'analyzing') {
|
||||
const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1;
|
||||
return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } };
|
||||
}
|
||||
return proof;
|
||||
});
|
||||
return { ...prevProofs, [campaignName]: updatedProofs };
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const feedback = await analyzeProof(file, handleAgentUpdate);
|
||||
const previewUrl = await fileToDataUrl(file);
|
||||
|
||||
if (feedback.overallStatus === 'Analysis Error') {
|
||||
const currentCampaignProofsList = campaignProofs[campaignName] || [];
|
||||
const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId);
|
||||
const version = existingProof ? (existingProof.versions[0]?.version || 0) + 1 : 1;
|
||||
|
||||
handleAddNewError({
|
||||
campaignName,
|
||||
proofName,
|
||||
version,
|
||||
errorSummary: feedback.leadAgentSummary,
|
||||
});
|
||||
}
|
||||
|
||||
setCampaignProofs(prevCampaignProofs => {
|
||||
const currentCampaignProofsList = prevCampaignProofs[campaignName] || [];
|
||||
const existingProof = currentCampaignProofsList.find(a => a.proofName === proofName && !a.tempId);
|
||||
|
||||
if (existingProof) {
|
||||
// UPDATE PROOF (NEW VERSION)
|
||||
const latestVersionNumber = existingProof.versions[0]?.version || 0;
|
||||
const newVersionNumber = latestVersionNumber + 1;
|
||||
const baseWorkfrontId = existingProof.versions.length > 0
|
||||
? existingProof.versions[existingProof.versions.length - 1].workfrontId.split('-V')[0]
|
||||
: `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
|
||||
|
||||
const newVersion = {
|
||||
version: newVersionNumber,
|
||||
timestamp: new Date().toISOString().split('T')[0],
|
||||
workfrontId: `${baseWorkfrontId}-V${newVersionNumber}`,
|
||||
proofPreviewUrl: previewUrl,
|
||||
feedback: feedback,
|
||||
overallStatus: feedback.overallStatus,
|
||||
};
|
||||
|
||||
const updatedProof = {
|
||||
...existingProof,
|
||||
overallStatus: feedback.overallStatus,
|
||||
versions: [newVersion, ...existingProof.versions]
|
||||
};
|
||||
|
||||
const updatedProofsList = currentCampaignProofsList
|
||||
.filter(proof => proof.tempId !== tempId)
|
||||
.map(proof => proof.proofName === proofName ? updatedProof : proof);
|
||||
|
||||
const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList };
|
||||
|
||||
setCampaigns(prevCampaigns => {
|
||||
const updatedCampaigns = prevCampaigns.map(p =>
|
||||
p.name === campaignName ? { ...p, lastModified: new Date().toISOString().split('T')[0] } : p
|
||||
);
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
|
||||
return updatedCampaigns;
|
||||
});
|
||||
|
||||
saveCampaignProofs(finalCampaignProofs);
|
||||
return finalCampaignProofs;
|
||||
|
||||
} else {
|
||||
// CREATE NEW PROOF
|
||||
const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
|
||||
const newProofWithVersion = {
|
||||
proofName,
|
||||
channel,
|
||||
subChannel,
|
||||
proofType,
|
||||
status: 'completed',
|
||||
overallStatus: feedback.overallStatus,
|
||||
versions: [
|
||||
{
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString().split('T')[0],
|
||||
workfrontId: `${newWorkfrontId}-V1`,
|
||||
proofPreviewUrl: previewUrl,
|
||||
feedback: feedback,
|
||||
overallStatus: feedback.overallStatus,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const updatedProofsList = currentCampaignProofsList.map(proof =>
|
||||
proof.tempId === tempId ? newProofWithVersion : proof
|
||||
);
|
||||
|
||||
const finalCampaignProofs = { ...prevCampaignProofs, [campaignName]: updatedProofsList };
|
||||
|
||||
setCampaigns(prevCampaigns => {
|
||||
const updatedCampaigns = prevCampaigns.map(p =>
|
||||
p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p
|
||||
);
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
|
||||
return updatedCampaigns;
|
||||
});
|
||||
|
||||
saveCampaignProofs(finalCampaignProofs);
|
||||
return finalCampaignProofs;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to upload and analyze proof:", err);
|
||||
setError("Failed to upload and analyze proof. Please try again.");
|
||||
setCampaignProofs(prevProofs => {
|
||||
const updatedProofs = {
|
||||
...prevProofs,
|
||||
[campaignName]: prevProofs[campaignName].map(proof =>
|
||||
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
|
||||
)
|
||||
};
|
||||
saveCampaignProofs(updatedProofs);
|
||||
return updatedProofs;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryAnalysis = async (campaignName: string, tempId: string) => {
|
||||
const proofToRetry = campaignProofs[campaignName]?.find(proof => proof.tempId === tempId);
|
||||
|
||||
if (!proofToRetry || !proofToRetry.file) {
|
||||
console.error("Proof to retry not found or file is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const { file, proofName, channel, subChannel, proofType } = proofToRetry;
|
||||
|
||||
setCampaignProofs(prevProofs => ({
|
||||
...prevProofs,
|
||||
[campaignName]: prevProofs[campaignName].map(proof =>
|
||||
proof.tempId === tempId ? { ...proof, status: 'analyzing', analysisProgress: { completed: 0, total: AGENT_NAMES.length + 1 } } : proof
|
||||
)
|
||||
}));
|
||||
|
||||
const handleAgentUpdateForRetry = (agentName: AgentName | 'Summary') => {
|
||||
setCampaignProofs(prevProofs => {
|
||||
const currentProofs = prevProofs[campaignName] || [];
|
||||
const updatedProofs = currentProofs.map(proof => {
|
||||
if (proof.tempId === tempId && proof.status === 'analyzing') {
|
||||
const newCompleted = (proof.analysisProgress?.completed ?? 0) + 1;
|
||||
return { ...proof, analysisProgress: { ...proof.analysisProgress, completed: newCompleted } };
|
||||
}
|
||||
return proof;
|
||||
});
|
||||
return { ...prevProofs, [campaignName]: updatedProofs };
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const feedback = await analyzeProof(file, handleAgentUpdateForRetry);
|
||||
const previewUrl = await fileToDataUrl(file);
|
||||
const newWorkfrontId = `#WF_${Math.floor(10000 + Math.random() * 90000)}`;
|
||||
|
||||
if (feedback.overallStatus === 'Analysis Error') {
|
||||
handleAddNewError({
|
||||
campaignName: campaignName,
|
||||
proofName: proofName,
|
||||
version: 1, // Retry always creates a V1
|
||||
errorSummary: feedback.leadAgentSummary,
|
||||
});
|
||||
}
|
||||
|
||||
const newProofWithVersion = {
|
||||
proofName,
|
||||
channel,
|
||||
subChannel,
|
||||
proofType,
|
||||
status: 'completed',
|
||||
overallStatus: feedback.overallStatus,
|
||||
versions: [
|
||||
{
|
||||
version: 1,
|
||||
timestamp: new Date().toISOString().split('T')[0],
|
||||
workfrontId: `${newWorkfrontId}-V1`,
|
||||
proofPreviewUrl: previewUrl,
|
||||
feedback: feedback,
|
||||
overallStatus: feedback.overallStatus,
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const updatedCampaigns = campaigns.map(p =>
|
||||
p.name === campaignName ? { ...p, proofs: p.proofs + 1, lastModified: new Date().toISOString().split('T')[0] } : p
|
||||
);
|
||||
setCampaigns(updatedCampaigns);
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
|
||||
|
||||
setCampaignProofs(prevProofs => {
|
||||
const updatedProofs = {
|
||||
...prevProofs,
|
||||
[campaignName]: prevProofs[campaignName].map(proof =>
|
||||
proof.tempId === tempId ? newProofWithVersion : proof
|
||||
)
|
||||
};
|
||||
saveCampaignProofs(updatedProofs);
|
||||
return updatedProofs;
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to retry proof analysis:", err);
|
||||
setCampaignProofs(prevProofs => {
|
||||
const updatedProofs = {
|
||||
...prevProofs,
|
||||
[campaignName]: prevProofs[campaignName].map(proof =>
|
||||
proof.tempId === tempId ? { ...proof, status: 'error' } : proof
|
||||
)
|
||||
};
|
||||
saveCampaignProofs(updatedProofs);
|
||||
return updatedProofs;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCampaignStatusChange = (campaignName: string, newStatus: 'In Progress' | 'Completed') => {
|
||||
const updatedCampaigns = campaigns.map(p =>
|
||||
p.name === campaignName ? { ...p, status: newStatus, lastModified: new Date().toISOString().split('T')[0] } : p
|
||||
);
|
||||
setCampaigns(updatedCampaigns);
|
||||
try {
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
|
||||
} catch (error) {
|
||||
console.error('Error saving campaign status to localStorage', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProof = (campaignName: string, proofName: string) => {
|
||||
// Update campaign proofs
|
||||
setCampaignProofs(prevProofs => {
|
||||
const updatedProofsForCampaign = prevProofs[campaignName].filter(
|
||||
proof => proof.proofName !== proofName
|
||||
);
|
||||
const finalProofs = { ...prevProofs, [campaignName]: updatedProofsForCampaign };
|
||||
saveCampaignProofs(finalProofs);
|
||||
return finalProofs;
|
||||
});
|
||||
|
||||
// Update campaigns list (proof count)
|
||||
setCampaigns(prevCampaigns => {
|
||||
const updatedCampaigns = prevCampaigns.map(p =>
|
||||
p.name === campaignName
|
||||
? { ...p, proofs: p.proofs > 0 ? p.proofs - 1 : 0, lastModified: new Date().toISOString().split('T')[0] }
|
||||
: p
|
||||
);
|
||||
localStorage.setItem('barclays_modcomms_campaigns_v3', JSON.stringify(updatedCampaigns));
|
||||
return updatedCampaigns;
|
||||
});
|
||||
};
|
||||
|
||||
// --- SETTINGS HANDLERS (UPDATED) ---
|
||||
|
||||
const handleAddCampaignOption = (value: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
if (!prev.campaigns.includes(value)) {
|
||||
return { ...prev, campaigns: [...prev.campaigns, value].sort() };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCampaignOption = (value: string) => {
|
||||
setDropdownOptions(prev => ({ ...prev, campaigns: prev.campaigns.filter(c => c !== value) }));
|
||||
};
|
||||
|
||||
const handleAddChannel = (channel: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
if (prev.channels[channel]) return prev;
|
||||
return {
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: {}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveChannel = (channel: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
const newChannels = { ...prev.channels };
|
||||
delete newChannels[channel];
|
||||
return { ...prev, channels: newChannels };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSubChannel = (channel: string, subChannel: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
const channelData = prev.channels[channel] || {};
|
||||
if (channelData[subChannel]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: {
|
||||
...channelData,
|
||||
[subChannel]: []
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveSubChannel = (channel: string, subChannel: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
const channelData = { ...prev.channels[channel] };
|
||||
delete channelData[subChannel];
|
||||
return {
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: channelData
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddProofType = (channel: string, subChannel: string, proofType: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
const channelData = prev.channels[channel];
|
||||
if (!channelData) return prev;
|
||||
const currentTypes = channelData[subChannel] || [];
|
||||
if (currentTypes.includes(proofType)) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: {
|
||||
...channelData,
|
||||
[subChannel]: [...currentTypes, proofType].sort()
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveProofType = (channel: string, subChannel: string, proofType: string) => {
|
||||
setDropdownOptions(prev => {
|
||||
const channelData = prev.channels[channel];
|
||||
if (!channelData) return prev;
|
||||
const currentTypes = channelData[subChannel] || [];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
channels: {
|
||||
...prev.channels,
|
||||
[channel]: {
|
||||
...channelData,
|
||||
[subChannel]: currentTypes.filter(t => t !== proofType)
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleNavigate = (view: View) => {
|
||||
setCurrentView(view);
|
||||
setSelectedCampaign(null); // Reset campaign on any main navigation
|
||||
setSelectedProof(null);
|
||||
};
|
||||
|
||||
const handleSelectCampaign = (campaignName: string) => {
|
||||
setSelectedCampaign(campaignName);
|
||||
};
|
||||
|
||||
const handleSelectProof = (proof: any) => {
|
||||
setSelectedProof(proof);
|
||||
};
|
||||
|
||||
const handleBackToCampaignsList = () => {
|
||||
setSelectedCampaign(null);
|
||||
setSelectedProof(null);
|
||||
};
|
||||
|
||||
const handleBackToCampaignDetails = () => {
|
||||
setSelectedProof(null);
|
||||
};
|
||||
|
||||
const handleFlagSubmit = (flagData: Omit<FlaggedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
|
||||
const newFlag: FlaggedItem = {
|
||||
...flagData,
|
||||
id: `flag_${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
submitter: "Steve O'Donoghue", // Hardcoded for prototype
|
||||
submitAgency: "OLIVER Agency", // Hardcoded for prototype
|
||||
};
|
||||
|
||||
const updatedFlags = [newFlag, ...flaggedItems];
|
||||
setFlaggedItems(updatedFlags);
|
||||
|
||||
try {
|
||||
localStorage.setItem('barclays_modcomms_flagged_items_v3', JSON.stringify(updatedFlags));
|
||||
} catch (error) {
|
||||
console.error('Error saving flagged items to localStorage', error);
|
||||
setError('Could not save your feedback due to storage limitations.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolveSubmit = (resolveData: Omit<ResolvedItem, 'id' | 'timestamp' | 'submitter' | 'submitAgency'>) => {
|
||||
const newResolution: ResolvedItem = {
|
||||
...resolveData,
|
||||
id: `res_${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
submitter: "Steve O'Donoghue", // Hardcoded for prototype
|
||||
submitAgency: "OLIVER Agency", // Hardcoded for prototype
|
||||
};
|
||||
|
||||
const updatedResolutions = [newResolution, ...resolvedItems];
|
||||
setResolvedItems(updatedResolutions);
|
||||
|
||||
try {
|
||||
localStorage.setItem('barclays_modcomms_resolved_items_v3', JSON.stringify(updatedResolutions));
|
||||
} catch (error) {
|
||||
console.error('Error saving resolved items to localStorage', error);
|
||||
setError('Could not save your resolution due to storage limitations.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleNavigateToAuditedItem = (item: { campaignName: string; proofName: string; version: number }) => {
|
||||
const proofToSelect = campaignProofs[item.campaignName]?.find(a => a.proofName === item.proofName);
|
||||
if (proofToSelect) {
|
||||
const versionExists = proofToSelect.versions.some((v: any) => v.version === item.version);
|
||||
if (versionExists) {
|
||||
setSelectedCampaign(item.campaignName);
|
||||
// Add a temporary property to the proof object to indicate which version to show.
|
||||
setSelectedProof({ ...proofToSelect, initialVersion: item.version });
|
||||
setCurrentView('Campaigns');
|
||||
} else {
|
||||
setError(`Version ${item.version} not found for proof ${item.proofName}. It may have been deleted.`);
|
||||
}
|
||||
} else {
|
||||
setError(`Proof ${item.proofName} not found in campaign ${item.campaignName}. It may have been deleted.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
setIsLoggedIn(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case 'Analytics':
|
||||
return <Analytics />;
|
||||
case 'Profile':
|
||||
return <Profile onLogout={handleLogout} />;
|
||||
case 'CopyGenAI':
|
||||
return <CopyGenAI />;
|
||||
case 'Campaigns':
|
||||
return <Campaigns
|
||||
selectedCampaign={selectedCampaign}
|
||||
selectedProof={selectedProof}
|
||||
onSelectCampaign={handleSelectCampaign}
|
||||
onSelectProof={handleSelectProof}
|
||||
onBackToCampaignsList={handleBackToCampaignsList}
|
||||
onBackToCampaignDetails={handleBackToCampaignDetails}
|
||||
campaigns={campaigns}
|
||||
campaignProofs={campaignProofs}
|
||||
onAddNewCampaign={handleAddNewCampaign}
|
||||
onProofUpload={handleProofUploadForCampaign}
|
||||
dropdownOptions={dropdownOptions}
|
||||
onRetryAnalysis={handleRetryAnalysis}
|
||||
onCampaignStatusChange={handleCampaignStatusChange}
|
||||
onDeleteProof={handleDeleteProof}
|
||||
onFlagSubmit={handleFlagSubmit}
|
||||
onResolveSubmit={handleResolveSubmit}
|
||||
/>;
|
||||
case 'WIP Reviewer':
|
||||
return <WIPReviewer dropdownOptions={dropdownOptions} />;
|
||||
case 'Auditing':
|
||||
return <Auditing
|
||||
flaggedItems={flaggedItems}
|
||||
resolvedItems={resolvedItems}
|
||||
errorItems={errorItems}
|
||||
onNavigate={handleNavigateToAuditedItem}
|
||||
/>;
|
||||
case 'Settings':
|
||||
return <Settings
|
||||
options={dropdownOptions}
|
||||
onAddCampaign={handleAddCampaignOption}
|
||||
onRemoveCampaign={handleRemoveCampaignOption}
|
||||
onAddChannel={handleAddChannel}
|
||||
onRemoveChannel={handleRemoveChannel}
|
||||
onAddSubChannel={handleAddSubChannel}
|
||||
onRemoveSubChannel={handleRemoveSubChannel}
|
||||
onAddProofType={handleAddProofType}
|
||||
onRemoveProofType={handleRemoveProofType}
|
||||
onNavigate={handleNavigate}
|
||||
/>;
|
||||
case 'Home':
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<Hero onGetStarted={() => handleNavigate('Campaigns')} />
|
||||
<ChecksOverview />
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
// Determine background color based on view to avoid grey bar on Home view
|
||||
const mainBgColor = currentView === 'Home' ? 'bg-white' : 'bg-brand-gray';
|
||||
|
||||
return (
|
||||
<div className={`flex h-screen ${mainBgColor} font-sans text-gray-800 overflow-hidden`}>
|
||||
<Sidebar activeItem={currentView} onNavigate={(view) => handleNavigate(view as View)} />
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<main className="flex-1 flex flex-col min-h-full">
|
||||
{renderContent()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
20
frontend/README.md
Normal file
20
frontend/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1vH-R-vj0Xkk_g2ZFdHtLxNc12sFTOl2L
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
117
frontend/components/Analytics.tsx
Normal file
117
frontend/components/Analytics.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React from 'react';
|
||||
import { UploadIcon } from './icons/UploadIcon';
|
||||
import { TrendingUpIcon } from './icons/TrendingUpIcon';
|
||||
import { BugIcon } from './icons/BugIcon';
|
||||
import { ClockIcon } from './icons/ClockIcon';
|
||||
import { LightbulbIcon } from './icons/LightbulbIcon';
|
||||
|
||||
const stats = [
|
||||
{ name: 'Proofs Uploaded', value: '57', icon: UploadIcon },
|
||||
{ name: 'Pass Rate', value: '76%', icon: TrendingUpIcon },
|
||||
{ name: 'Issues Found', value: '34', icon: BugIcon },
|
||||
{ name: 'Time Saved', value: '93 hours', icon: ClockIcon },
|
||||
];
|
||||
|
||||
const agentPerformance = [
|
||||
{ name: 'Legal Agent', passRate: 85, avgIssues: 1.2, trend: 'up' },
|
||||
{ name: 'Brand Agent', passRate: 68, avgIssues: 2.5, trend: 'down' },
|
||||
{ name: 'Tone Agent', passRate: 92, avgIssues: 0.8, trend: 'up' },
|
||||
{ name: 'Channel Agent', passRate: 71, avgIssues: 1.9, trend: 'stable' },
|
||||
];
|
||||
|
||||
const UpArrow = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor" className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /></svg>;
|
||||
const DownArrow = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor" className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /></svg>;
|
||||
const StableLine = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={3} stroke="currentColor" className="h-4 w-4"><path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5" /></svg>;
|
||||
|
||||
const TrendIndicator: React.FC<{ trend: 'up' | 'down' | 'stable' }> = ({ trend }) => {
|
||||
if (trend === 'up') {
|
||||
return <div className="flex items-center gap-1.5 text-green-600"><UpArrow/> Improving</div>;
|
||||
}
|
||||
if (trend === 'down') {
|
||||
return <div className="flex items-center gap-1.5 text-red-600"><DownArrow/> Declining</div>;
|
||||
}
|
||||
return <div className="flex items-center gap-1.5 text-gray-500"><StableLine/> Stable</div>;
|
||||
};
|
||||
|
||||
export const Analytics: React.FC = () => {
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Performance Analytics</h1>
|
||||
<p className="text-base lg:text-lg text-gray-600 mt-1">Overall usage and performance statistics for the tool.</p>
|
||||
</header>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<section>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.name} className="bg-white rounded-lg shadow-md p-6 flex items-start border border-gray-200 transition-all hover:shadow-xl hover:border-brand-accent">
|
||||
<div className="p-3 rounded-full bg-brand-light-blue/20 text-brand-accent mr-4 flex-shrink-0">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
|
||||
<p className="text-3xl font-bold text-brand-dark-blue mt-1">{stat.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI Performance Summary */}
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">AI Performance Summary</h2>
|
||||
<div className="bg-blue-50 border-l-4 border-brand-accent text-brand-dark-blue p-6 rounded-r-lg shadow-md flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<LightbulbIcon className="h-7 w-7 text-brand-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">Key Insight (Last 7 Days):</p>
|
||||
<p className="mt-1">
|
||||
A sharp decline in Best Practice adherence has been noted, primarily driven by proofs from the <strong>Barclays Q4 Social</strong> campaign. The Brand Guardian agent also shows a declining performance trend, suggesting a potential need for updated brand guideline training or proof review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Agent Performance Table */}
|
||||
<section className="mt-10">
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">Agent Performance (Last 7 Days)</h2>
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent Name</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Pass Rate</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Avg. Issues per Proof</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Performance Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{agentPerformance.map((agent) => (
|
||||
<tr key={agent.name} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{agent.name}</td>
|
||||
<td className={`px-6 py-4 whitespace-nowrap text-sm font-semibold`}>
|
||||
<div className="flex items-center">
|
||||
<span className={`h-2.5 w-2.5 rounded-full mr-3 ${agent.passRate >= 80 ? 'bg-green-500' : agent.passRate < 70 ? 'bg-red-500' : 'bg-yellow-500'}`}></span>
|
||||
{agent.passRate}%
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{agent.avgIssues}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<TrendIndicator trend={agent.trend as 'up' | 'down' | 'stable'} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
frontend/components/AssetPreview.tsx
Normal file
84
frontend/components/AssetPreview.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import { DocumentIcon } from './icons/DocumentIcon';
|
||||
|
||||
interface AssetPreviewProps {
|
||||
file?: File | null;
|
||||
previewUrl: string | null;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const AssetPreview: React.FC<AssetPreviewProps> = ({ file, previewUrl, fileName }) => {
|
||||
if (!previewUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getMimeType = (): string => {
|
||||
if (file?.type) return file.type;
|
||||
if (previewUrl.startsWith('data:')) {
|
||||
const match = previewUrl.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);/);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return 'application/octet-stream'; // Fallback
|
||||
};
|
||||
|
||||
const fileType = getMimeType();
|
||||
const displayName = fileName || file?.name || 'Asset Preview';
|
||||
|
||||
|
||||
const renderPreview = () => {
|
||||
if (fileType.startsWith('image/')) {
|
||||
return (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={displayName}
|
||||
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
|
||||
style={{ maxHeight: 'calc(100vh - 9rem)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'video/mp4') {
|
||||
return (
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls
|
||||
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
|
||||
style={{ maxHeight: 'calc(100vh - 9rem)' }}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'application/pdf') {
|
||||
return (
|
||||
<iframe
|
||||
src={`${previewUrl}#view=fitH`}
|
||||
title={displayName}
|
||||
className="w-full h-[calc(100vh-9rem)] rounded-lg shadow-2xl border border-gray-200 bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for other file types
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-lg shadow-2xl border border-gray-200 bg-white p-8 flex flex-col items-center justify-center text-center"
|
||||
style={{ minHeight: '300px', maxHeight: 'calc(100vh - 9rem)' }}
|
||||
>
|
||||
<DocumentIcon className="h-20 w-20 text-gray-400 mb-4" />
|
||||
<p className="text-lg font-semibold text-brand-dark-blue break-all">{displayName}</p>
|
||||
<p className="text-sm text-gray-500">{fileType}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">No preview available for this file type.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticky top-8">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
frontend/components/AssetUpload.tsx
Normal file
54
frontend/components/AssetUpload.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useRef } from 'react';
|
||||
|
||||
interface AssetUploadProps {
|
||||
onFileUpload: (file: File) => void;
|
||||
isLoading: boolean;
|
||||
isUploadDisabled?: boolean;
|
||||
}
|
||||
|
||||
export const AssetUpload: React.FC<AssetUploadProps> = ({ onFileUpload, isLoading, isUploadDisabled }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
onFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const isDisabled = isLoading || isUploadDisabled;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
accept="image/png, image/jpeg, image/webp, image/gif, video/mp4, application/pdf"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
title={isUploadDisabled ? "Please complete all selections above" : "Upload an asset for review"}
|
||||
className="w-full bg-brand-light-blue text-brand-dark-blue font-bold py-3 px-6 rounded-full hover:bg-white transition-all duration-300 disabled:bg-gray-500 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</>
|
||||
) : 'Upload Asset'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-400 mt-4">Supports PNG, JPG, GIF, WEBP, MP4, PDF.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
219
frontend/components/Auditing.tsx
Normal file
219
frontend/components/Auditing.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import React, { useState } from 'react';
|
||||
import type { FlaggedItem, ResolvedItem, ErrorItem } from '../types';
|
||||
|
||||
interface AuditingProps {
|
||||
flaggedItems: FlaggedItem[];
|
||||
resolvedItems: ResolvedItem[];
|
||||
errorItems: ErrorItem[];
|
||||
onNavigate: (item: { campaignName: string, proofName: string, version: number }) => void;
|
||||
}
|
||||
|
||||
const formatDate = (isoString: string) => {
|
||||
try {
|
||||
return new Date(isoString).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (e) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
};
|
||||
|
||||
const FlagsTable: React.FC<{ items: FlaggedItem[], onNavigate: AuditingProps['onNavigate'] }> = ({ items, onNavigate }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Version</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submitter</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submit Agency</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent Flagged</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">User Comments</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => onNavigate(item)}
|
||||
title={`Click to view Version ${item.version} of ${item.proofName}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{item.proofName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">Version {item.version}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitter}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitAgency}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.agentFlagged}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
<div className="max-w-xs break-words" title={item.comments}>
|
||||
{item.comments || <span className="italic text-gray-400">No comment</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{formatDate(item.timestamp)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-10 text-gray-500">
|
||||
There are currently no flagged items to audit.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ResolutionsTable: React.FC<{ items: ResolvedItem[], onNavigate: AuditingProps['onNavigate'] }> = ({ items, onNavigate }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Version</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submitter</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submit Agency</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Agent Issue</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">User Comments</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => onNavigate(item)}
|
||||
title={`Click to view Version ${item.version} of ${item.proofName}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{item.proofName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">Version {item.version}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitter}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitAgency}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.agent}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
<div className="max-w-xs break-words" title={item.issue}>{item.issue}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
<div className="max-w-xs break-words" title={item.resolution}>{item.resolution}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{formatDate(item.timestamp)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="text-center py-10 text-gray-500">
|
||||
There are currently no resolved items to audit.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorsTable: React.FC<{ items: ErrorItem[], onNavigate: AuditingProps['onNavigate'] }> = ({ items, onNavigate }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Name</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Proof Version</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submitter</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Submit Agency</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Error Summary</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{items.length > 0 ? items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-gray-100 cursor-pointer"
|
||||
onClick={() => onNavigate(item)}
|
||||
title={`Click to view Version ${item.version} of ${item.proofName}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-brand-dark-blue">{item.proofName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">Version {item.version}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitter}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{item.submitAgency}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">
|
||||
<div className="max-w-md break-words" title={item.errorSummary}>
|
||||
{item.errorSummary}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{formatDate(item.timestamp)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-10 text-gray-500">
|
||||
There are currently no analysis errors to audit.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
export const Auditing: React.FC<AuditingProps> = ({ flaggedItems, resolvedItems, errorItems, onNavigate }) => {
|
||||
const [activeTab, setActiveTab] = useState<'Flags' | 'Resolutions' | 'Errors'>('Flags');
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Auditing</h1>
|
||||
<p className="text-base lg:text-lg text-gray-600 mt-1">Review and investigate all user-flagged feedback.</p>
|
||||
</header>
|
||||
|
||||
<div className="mb-6 border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-6" aria-label="Tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('Flags')}
|
||||
className={`whitespace-nowrap py-3 px-1 border-b-2 font-semibold text-sm transition-colors ${
|
||||
activeTab === 'Flags'
|
||||
? 'border-brand-accent text-brand-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
aria-current={activeTab === 'Flags' ? 'page' : undefined}
|
||||
>
|
||||
Flags
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('Resolutions')}
|
||||
className={`whitespace-nowrap py-3 px-1 border-b-2 font-semibold text-sm transition-colors ${
|
||||
activeTab === 'Resolutions'
|
||||
? 'border-brand-accent text-brand-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
aria-current={activeTab === 'Resolutions' ? 'page' : undefined}
|
||||
>
|
||||
Resolutions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('Errors')}
|
||||
className={`whitespace-nowrap py-3 px-1 border-b-2 font-semibold text-sm transition-colors ${
|
||||
activeTab === 'Errors'
|
||||
? 'border-brand-accent text-brand-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
aria-current={activeTab === 'Errors' ? 'page' : undefined}
|
||||
>
|
||||
Errors
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
|
||||
{activeTab === 'Flags' && <FlagsTable items={flaggedItems} onNavigate={onNavigate} />}
|
||||
{activeTab === 'Resolutions' && <ResolutionsTable items={resolvedItems} onNavigate={onNavigate} />}
|
||||
{activeTab === 'Errors' && <ErrorsTable items={errorItems} onNavigate={onNavigate} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1327
frontend/components/Campaigns.tsx
Normal file
1327
frontend/components/Campaigns.tsx
Normal file
File diff suppressed because it is too large
Load diff
162
frontend/components/ChecksOverview.tsx
Normal file
162
frontend/components/ChecksOverview.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
|
||||
import React from 'react';
|
||||
import { LegalIcon } from './icons/LegalIcon';
|
||||
import { BrandIcon } from './icons/BrandIcon';
|
||||
import { CopyIcon } from './icons/CopyIcon';
|
||||
import { ChannelIcon } from './icons/ChannelIcon';
|
||||
import { LeadAgentIcon } from './icons/LeadAgentIcon';
|
||||
|
||||
interface CheckDetail {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
role: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const specialistAgents: CheckDetail[] = [
|
||||
{
|
||||
name: 'Legal Agent',
|
||||
icon: <LegalIcon />,
|
||||
role: 'Standards & Disclaimers',
|
||||
description: 'Ensures compliance with all regulatory requirements.'
|
||||
},
|
||||
{
|
||||
name: 'Brand Agent',
|
||||
icon: <BrandIcon />,
|
||||
role: 'Visual Identity',
|
||||
description: 'Verifies logo usage, color palette, and visual identity.'
|
||||
},
|
||||
{
|
||||
name: 'Tone Agent',
|
||||
icon: <CopyIcon />,
|
||||
role: 'Voice & Clarity',
|
||||
description: 'Analyzes copy for clarity, brand voice, and tone consistency.'
|
||||
},
|
||||
{
|
||||
name: 'Channel Agent',
|
||||
icon: <ChannelIcon />,
|
||||
role: 'Platform Specs',
|
||||
description: 'Checks assets against platform-specific technical specifications.'
|
||||
}
|
||||
];
|
||||
|
||||
export const ChecksOverview: React.FC = () => {
|
||||
return (
|
||||
<div className="relative bg-gradient-to-br from-emerald-50 via-white to-emerald-50/30 py-16 overflow-hidden flex-1 w-full">
|
||||
{/* Decorative Background */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[800px] h-[800px] bg-emerald-200/30 rounded-full blur-3xl -translate-y-1/2 mix-blend-multiply"></div>
|
||||
<div className="absolute bottom-0 right-0 w-[600px] h-[600px] bg-green-200/30 rounded-full blur-3xl translate-y-1/3 mix-blend-multiply"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
{/* Header */}
|
||||
<div className="max-w-3xl mx-auto text-center mb-12">
|
||||
<div className="inline-flex items-center px-4 py-1.5 rounded-full bg-white border border-emerald-200 text-emerald-700 text-xs font-bold uppercase tracking-widest mb-4 shadow-sm">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-2 animate-pulse"></span>
|
||||
Intelligent Workflow
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-extrabold text-brand-dark-blue tracking-tight mb-4">
|
||||
Orchestrated by AI
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 leading-relaxed">
|
||||
A central Lead Agent coordinates specialized experts to review every aspect of your content in parallel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row items-center justify-center lg:items-stretch gap-0 lg:gap-0">
|
||||
|
||||
{/* 1. LEAD AGENT CARD (Left) */}
|
||||
<div className="w-full max-w-md lg:w-[380px] relative z-20 group/lead flex flex-col">
|
||||
<div className="flex-1 relative bg-[#001f5a] rounded-3xl p-6 shadow-2xl shadow-blue-900/20 border border-white/10 text-center transition-all duration-500 hover:-translate-y-2 hover:shadow-blue-900/40 overflow-hidden flex flex-col items-center justify-center">
|
||||
{/* Animated Gradient bg */}
|
||||
<div className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] bg-gradient-to-b from-transparent via-white/5 to-transparent rotate-45 transition-transform duration-[3s] group-hover/lead:translate-y-10"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="h-20 w-20 mx-auto rounded-2xl bg-white/10 border border-white/20 backdrop-blur-md flex items-center justify-center mb-4 shadow-lg shadow-brand-dark-blue/30 group-hover/lead:scale-110 transition-all duration-500">
|
||||
<LeadAgentIcon className="h-10 w-10 text-white" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-white mb-2">Lead Agent</h3>
|
||||
<p className="text-brand-light-blue font-bold text-xs uppercase tracking-widest mb-4">Orchestrator</p>
|
||||
<p className="text-slate-300 text-sm leading-relaxed">
|
||||
Synthesizes feedback & coordinates analysis across all specialist agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Node (Desktop) */}
|
||||
<div className="hidden lg:flex absolute top-1/2 -right-4 w-8 h-8 bg-[#001f5a] rounded-full border-4 border-emerald-50 items-center justify-center z-30 -translate-y-1/2 shadow-sm translate-x-1/2">
|
||||
<div className="w-2.5 h-2.5 bg-emerald-400 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
{/* Connection Node (Mobile) */}
|
||||
<div className="lg:hidden absolute -bottom-4 left-1/2 w-8 h-8 bg-[#001f5a] rounded-full border-4 border-emerald-50 items-center justify-center z-30 -translate-x-1/2 shadow-sm">
|
||||
<div className="w-2.5 h-2.5 bg-emerald-400 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. CONNECTOR LINES (Desktop SVG) */}
|
||||
<div className="hidden lg:block w-24 relative z-10 self-center h-48">
|
||||
<svg className="absolute inset-0 w-full h-full overflow-visible" viewBox="0 0 100 200" preserveAspectRatio="none">
|
||||
{/* Connecting paths */}
|
||||
<path d="M0,100 C50,100 50,30 100,30" fill="none" stroke="#6ee7b7" strokeWidth="2" strokeDasharray="6 6" />
|
||||
<path d="M0,100 C50,100 50,170 100,170" fill="none" stroke="#6ee7b7" strokeWidth="2" strokeDasharray="6 6" />
|
||||
|
||||
{/* Animated pulses on paths */}
|
||||
<circle r="3" fill="#10b981">
|
||||
<animateMotion dur="2s" repeatCount="indefinite" path="M0,100 C50,100 50,30 100,30" />
|
||||
</circle>
|
||||
<circle r="3" fill="#10b981">
|
||||
<animateMotion dur="2s" repeatCount="indefinite" begin="1s" path="M0,100 C50,100 50,170 100,170" />
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Mobile Connector Spacer */}
|
||||
<div className="lg:hidden h-8 w-0.5 bg-emerald-300 my-3"></div>
|
||||
|
||||
{/* 3. SPECIALIST AGENTS (Right) */}
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-4xl pl-0 lg:pl-4">
|
||||
{specialistAgents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.name}
|
||||
className="group relative rounded-2xl p-5 border border-emerald-100 shadow-sm transition-all duration-500 hover:shadow-xl hover:shadow-emerald-900/5 hover:-translate-y-2 hover:border-emerald-200 overflow-hidden bg-white"
|
||||
>
|
||||
{/* Base Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white via-white to-emerald-50/30 opacity-100 transition-opacity duration-500 group-hover:opacity-0"></div>
|
||||
|
||||
{/* Hover Gradient - Interactive Green */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white via-emerald-50 to-emerald-100/20 opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
|
||||
{/* Animated Shine/Sweep Effect */}
|
||||
<div className="absolute top-[-50%] left-[-50%] w-[200%] h-[200%] bg-gradient-to-b from-transparent via-emerald-500/5 to-transparent rotate-45 translate-y-12 transition-transform duration-1000 group-hover:translate-y-[-10%] pointer-events-none"></div>
|
||||
|
||||
{/* Glassy Highlight */}
|
||||
<div className="absolute -top-24 -right-24 w-48 h-48 bg-gradient-to-br from-emerald-400/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
|
||||
|
||||
<div className="relative z-10 flex items-start gap-4">
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-2xl flex items-center justify-center transition-all duration-300 group-hover:scale-110 group-hover:shadow-md bg-emerald-50 text-emerald-600 group-hover:bg-emerald-600 group-hover:text-white`}>
|
||||
{React.cloneElement(agent.icon as React.ReactElement, { className: "h-6 w-6 transition-colors duration-300" })}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h4 className="text-base font-bold text-slate-800 group-hover:text-emerald-900 transition-colors duration-300 mb-0.5">
|
||||
{agent.name}
|
||||
</h4>
|
||||
<p className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider mb-1 group-hover:text-emerald-600 transition-colors duration-300">
|
||||
{agent.role}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 leading-relaxed group-hover:text-slate-700">
|
||||
{agent.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
frontend/components/CopyGenAI.tsx
Normal file
110
frontend/components/CopyGenAI.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import React, { useState } from 'react';
|
||||
import { PlusIcon } from './icons/PlusIcon';
|
||||
import { ChatBubbleIcon } from './icons/ChatBubbleIcon';
|
||||
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
||||
import { SendIcon } from './icons/SendIcon';
|
||||
|
||||
const mockConversations = [
|
||||
{ id: 1, title: 'Q4 Social Media Campaign Snippets' },
|
||||
{ id: 2, title: 'Email Subject Line Variations' },
|
||||
{ id: 3, title: 'New Product Launch Announcement' },
|
||||
{ id: 4, title: 'Website Homepage Copy Draft' },
|
||||
];
|
||||
|
||||
export const CopyGenAI: React.FC = () => {
|
||||
const [activeConversationId, setActiveConversationId] = useState<number>(1);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
const handleSendMessage = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim()) return;
|
||||
// Logic to send message will be added here
|
||||
console.log('Sending message:', message);
|
||||
setMessage('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-brand-gray">
|
||||
{/* Conversation History Sidebar */}
|
||||
<aside className="w-80 flex-shrink-0 bg-gray-100 border-r border-gray-200 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<button className="w-full flex items-center justify-center gap-2 bg-brand-accent text-white font-semibold py-2.5 px-4 rounded-lg hover:bg-brand-dark-blue transition-colors duration-300">
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Start new conversation
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<nav className="p-2 space-y-1">
|
||||
{mockConversations.map((convo) => (
|
||||
<button
|
||||
key={convo.id}
|
||||
onClick={() => setActiveConversationId(convo.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 text-left text-sm font-medium rounded-md transition-colors duration-200 ${
|
||||
activeConversationId === convo.id
|
||||
? 'bg-brand-accent/10 text-brand-accent'
|
||||
: 'text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<ChatBubbleIcon className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="truncate flex-1">{convo.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<main className="flex-1 flex flex-col">
|
||||
<header className="flex-shrink-0 bg-white/80 backdrop-blur-sm border-b border-gray-200 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="flex items-center gap-2 bg-white text-gray-800 font-semibold py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-100 transition-colors duration-200">
|
||||
<span>Select an assistant</span>
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
<button className="flex items-center gap-2 bg-white text-gray-800 font-semibold py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-100 transition-colors duration-200">
|
||||
<span>Barclays</span>
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Message Display */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-brand-dark-blue">CopyGenAI</h1>
|
||||
<p className="mt-2 text-gray-600">How can I help you today?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Input */}
|
||||
<div className="flex-shrink-0 p-4 bg-white/80 backdrop-blur-sm border-t border-gray-200">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<form onSubmit={handleSendMessage} className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage(e);
|
||||
}
|
||||
}}
|
||||
className="w-full p-3 pr-14 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
|
||||
placeholder="Type your message here..."
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-full text-white bg-brand-accent hover:bg-brand-dark-blue disabled:bg-gray-400 transition-colors"
|
||||
disabled={!message.trim()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<SendIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
181
frontend/components/CreateCampaignModal.tsx
Normal file
181
frontend/components/CreateCampaignModal.tsx
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { XIcon } from './icons/XIcon';
|
||||
|
||||
interface CreateCampaignModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddCampaign: (campaignData: {
|
||||
name: string;
|
||||
workfrontId: string;
|
||||
clientLead: string;
|
||||
brandGuidelines: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const CreateCampaignModal: React.FC<CreateCampaignModalProps> = ({ isOpen, onClose, onAddCampaign }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [brandGuidelines, setBrandGuidelines] = useState('');
|
||||
const [workfrontId, setWorkfrontId] = useState('');
|
||||
const [clientLead, setClientLead] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset form when modal opens
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setBrandGuidelines('');
|
||||
setWorkfrontId('');
|
||||
setClientLead('');
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const validateWorkfrontId = (id: string): boolean => {
|
||||
const isValid = /^#WF_\d+$/.test(id);
|
||||
if (!isValid && id.length > 0) {
|
||||
setError("Workfront Campaign ID must be in the format '#WF_12345'");
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
return isValid || id.length === 0;
|
||||
};
|
||||
|
||||
const handleWorkfrontIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newId = e.target.value;
|
||||
setWorkfrontId(newId);
|
||||
validateWorkfrontId(newId);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isIdValidOnSubmit = /^#WF_\d+$/.test(workfrontId);
|
||||
|
||||
if (!name.trim() || !clientLead.trim() || !workfrontId.trim() || !brandGuidelines.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIdValidOnSubmit) {
|
||||
setError("Workfront Campaign ID must be in the format '#WF_12345'");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onAddCampaign({ name, workfrontId, clientLead, brandGuidelines });
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim() || !brandGuidelines.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-lg transform transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue">Create New Campaign</h2>
|
||||
<button onClick={onClose} className="p-1 rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-800 transition-colors">
|
||||
<XIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="campaign-name" className="block text-sm font-medium text-gray-700">Campaign Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="campaign-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="brand-guidelines" className="block text-sm font-medium text-gray-700">Brand Guidelines</label>
|
||||
<select
|
||||
id="brand-guidelines"
|
||||
value={brandGuidelines}
|
||||
onChange={(e) => setBrandGuidelines(e.target.value)}
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>Select brand guidelines</option>
|
||||
<option value="Barclays">Barclays</option>
|
||||
<option value="Barclaycard">Barclaycard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="workfront-id" className="block text-sm font-medium text-gray-700">Workfront Campaign ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="workfront-id"
|
||||
value={workfrontId}
|
||||
onChange={handleWorkfrontIdChange}
|
||||
className={`mt-1 block w-full p-2 border rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900 placeholder:text-gray-400 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300'}`}
|
||||
placeholder="#WF_12345"
|
||||
required
|
||||
aria-invalid={!!error}
|
||||
aria-describedby="workfront-id-error"
|
||||
/>
|
||||
{error && <p id="workfront-id-error" className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="client-lead" className="block text-sm font-medium text-gray-700">Client Lead</label>
|
||||
<input
|
||||
type="text"
|
||||
id="client-lead"
|
||||
value={clientLead}
|
||||
onChange={(e) => setClientLead(e.target.value)}
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Agency</label>
|
||||
<input
|
||||
type="text"
|
||||
value="OLIVER Agency"
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Agency Lead</label>
|
||||
<input
|
||||
type="text"
|
||||
value="Steve O'Donoghue"
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
disabled={isFormInvalid || !!error}
|
||||
>
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
163
frontend/components/CreateProjectModal.tsx
Normal file
163
frontend/components/CreateProjectModal.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { XIcon } from './icons/XIcon';
|
||||
|
||||
interface CreateProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAddProject: (projectData: {
|
||||
name: string;
|
||||
workfrontId: string;
|
||||
clientLead: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({ isOpen, onClose, onAddProject }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [workfrontId, setWorkfrontId] = useState('');
|
||||
const [clientLead, setClientLead] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset form when modal opens
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setWorkfrontId('');
|
||||
setClientLead('');
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const validateWorkfrontId = (id: string): boolean => {
|
||||
const isValid = /^#WF_\d+$/.test(id);
|
||||
if (!isValid && id.length > 0) {
|
||||
setError("Workfront Project ID must be in the format '#WF_12345'");
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
return isValid || id.length === 0;
|
||||
};
|
||||
|
||||
const handleWorkfrontIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newId = e.target.value;
|
||||
setWorkfrontId(newId);
|
||||
validateWorkfrontId(newId);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isIdValidOnSubmit = /^#WF_\d+$/.test(workfrontId);
|
||||
|
||||
if (!name.trim() || !clientLead.trim() || !workfrontId.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIdValidOnSubmit) {
|
||||
setError("Workfront Project ID must be in the format '#WF_12345'");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
onAddProject({ name, workfrontId, clientLead });
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isFormInvalid = !name.trim() || !workfrontId.trim() || !clientLead.trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-lg transform transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue">Create New Project</h2>
|
||||
<button onClick={onClose} className="p-1 rounded-full text-gray-500 hover:bg-gray-200 hover:text-gray-800 transition-colors">
|
||||
<XIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="project-name" className="block text-sm font-medium text-gray-700">Project Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="workfront-id" className="block text-sm font-medium text-gray-700">Workfront Project ID</label>
|
||||
<input
|
||||
type="text"
|
||||
id="workfront-id"
|
||||
value={workfrontId}
|
||||
onChange={handleWorkfrontIdChange}
|
||||
className={`mt-1 block w-full p-2 border rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900 placeholder:text-gray-400 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : 'border-gray-300'}`}
|
||||
placeholder="#WF_12345"
|
||||
required
|
||||
aria-invalid={!!error}
|
||||
aria-describedby="workfront-id-error"
|
||||
/>
|
||||
{error && <p id="workfront-id-error" className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="client-lead" className="block text-sm font-medium text-gray-700">Client Lead</label>
|
||||
<input
|
||||
type="text"
|
||||
id="client-lead"
|
||||
value={clientLead}
|
||||
onChange={(e) => setClientLead(e.target.value)}
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-brand-accent focus:border-brand-accent transition bg-white text-gray-900"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Agency</label>
|
||||
<input
|
||||
type="text"
|
||||
value="OLIVER Agency"
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Agency Lead</label>
|
||||
<input
|
||||
type="text"
|
||||
value="Steve O'Donoghue"
|
||||
className="mt-1 block w-full p-2 border border-gray-300 rounded-md shadow-sm bg-gray-100 text-gray-600 cursor-not-allowed"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
disabled={isFormInvalid || !!error}
|
||||
>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
537
frontend/components/FeedbackReport.tsx
Normal file
537
frontend/components/FeedbackReport.tsx
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import type { AgentReview, SubReview, RagStatus, OverallStatus } from '../types';
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } from './icons/StatusIcons';
|
||||
import { FlagIcon } from './icons/FlagIcon';
|
||||
import { XIcon } from './icons/XIcon';
|
||||
import { BugIcon } from './icons/BugIcon';
|
||||
import { ExportIcon } from './icons/ExportIcon';
|
||||
import { LegalIcon } from './icons/LegalIcon';
|
||||
|
||||
|
||||
const RagStatusBadge: React.FC<{ status: RagStatus; isLarge?: boolean }> = ({ status, isLarge = false }) => {
|
||||
let colorClasses = '';
|
||||
let iconColor = '';
|
||||
|
||||
switch (status) {
|
||||
case 'Red':
|
||||
colorClasses = 'bg-red-50 border-red-200 text-red-800 shadow-red-100';
|
||||
iconColor = 'text-red-600';
|
||||
break;
|
||||
case 'Amber':
|
||||
colorClasses = 'bg-amber-50 border-amber-200 text-amber-800 shadow-amber-100';
|
||||
iconColor = 'text-amber-600';
|
||||
break;
|
||||
case 'Green':
|
||||
colorClasses = 'bg-emerald-50 border-emerald-200 text-emerald-800 shadow-emerald-100';
|
||||
iconColor = 'text-emerald-600';
|
||||
break;
|
||||
case 'Error':
|
||||
colorClasses = 'bg-slate-50 border-slate-200 text-slate-700 shadow-slate-100';
|
||||
iconColor = 'text-slate-500';
|
||||
break;
|
||||
}
|
||||
|
||||
const sizeClasses = isLarge ? 'px-4 py-1.5 text-sm rounded-xl border shadow-sm' : 'px-2.5 py-1 text-xs rounded-lg border shadow-sm';
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center font-bold tracking-wide ${sizeClasses} ${colorClasses} backdrop-blur-sm`}>
|
||||
{status === 'Red' && <ExclamationTriangleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
||||
{status === 'Amber' && <InformationCircleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
||||
{status === 'Green' && <CheckCircleIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
||||
{status === 'Error' && <BugIcon className={`h-4 w-4 mr-1.5 ${iconColor}`} />}
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResolveIssueModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: string) => void;
|
||||
issueText: string;
|
||||
}> = ({ isOpen, onClose, onSubmit, issueText }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [reason, setReason] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (reason.trim()) {
|
||||
onSubmit(reason);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-lg transform transition-all border border-white/20 ring-1 ring-black/5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-brand-dark-blue mb-2">Resolve Issue</h3>
|
||||
<p className="text-slate-600 mb-6">Please provide a reason for manually resolving this issue.</p>
|
||||
|
||||
<div className="my-6 p-4 bg-slate-50 border border-slate-200 rounded-xl text-slate-700 italic text-sm">
|
||||
"{issueText}"
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="resolution-reason" className="block text-sm font-bold text-slate-700 mb-2">Reason for resolution</label>
|
||||
<textarea
|
||||
id="resolution-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-brand-accent/50 focus:border-brand-accent transition-all bg-slate-50 focus:bg-white resize-none"
|
||||
rows={4}
|
||||
placeholder="e.g. 'Legal team has approved this exception via email...'"
|
||||
required
|
||||
/>
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl text-slate-600 font-semibold hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2.5 rounded-xl bg-brand-accent text-white font-bold shadow-lg shadow-brand-accent/30 hover:bg-brand-light-blue transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!reason.trim()}
|
||||
>
|
||||
Submit Resolution
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FlagIssueModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (comments: string) => void;
|
||||
agentName: string;
|
||||
}> = ({ isOpen, onClose, onSubmit, agentName }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [comments, setComments] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(comments);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-50 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-lg transform transition-all border border-white/20 ring-1 ring-black/5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-brand-dark-blue">Flag Feedback</h3>
|
||||
<p className="text-slate-500 text-sm mt-1">Reporting incorrect feedback from <span className="font-semibold text-brand-accent">{agentName}</span></p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<XIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label htmlFor="flag-comments" className="block text-sm font-bold text-slate-700 mb-2">Additional Comments</label>
|
||||
<textarea
|
||||
id="flag-comments"
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
className="w-full p-4 border border-slate-200 rounded-xl focus:ring-2 focus:ring-red-500/30 focus:border-red-500 transition-all bg-slate-50 focus:bg-white resize-none"
|
||||
rows={5}
|
||||
placeholder="Please explain why this feedback is incorrect..."
|
||||
/>
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl text-slate-600 font-semibold hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2.5 rounded-xl bg-red-600 text-white font-bold shadow-lg shadow-red-600/30 hover:bg-red-500 transition-all"
|
||||
>
|
||||
Submit Flag
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SubReviewCard: React.FC<{
|
||||
title: string;
|
||||
review: SubReview;
|
||||
onFlag: () => void;
|
||||
onResolve: (issueText: string, reason: string) => void;
|
||||
}> = ({ title, review, onFlag, onResolve }) => {
|
||||
interface IssueState {
|
||||
text: string;
|
||||
status: 'actionable' | 'resolved';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const [issues, setIssues] = useState<IssueState[]>([]);
|
||||
const [currentStatus, setCurrentStatus] = useState<RagStatus>(review.ragStatus);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [activeIssueIndex, setActiveIssueIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIssues(review.issues.map(text => ({ text, status: 'actionable', reason: undefined })));
|
||||
setCurrentStatus(review.ragStatus);
|
||||
}, [review]);
|
||||
|
||||
useEffect(() => {
|
||||
if (review.ragStatus === 'Error') {
|
||||
setCurrentStatus('Error');
|
||||
return;
|
||||
}
|
||||
if (issues.length > 0 && issues.every(issue => issue.status === 'resolved')) {
|
||||
setCurrentStatus('Green');
|
||||
} else if (issues.some(issue => issue.status === 'actionable')) {
|
||||
setCurrentStatus(review.ragStatus);
|
||||
}
|
||||
}, [issues, review.ragStatus]);
|
||||
|
||||
|
||||
const handleOpenModal = (index: number) => {
|
||||
setActiveIssueIndex(index);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setActiveIssueIndex(null);
|
||||
};
|
||||
|
||||
const handleResolve = (reason: string) => {
|
||||
if (activeIssueIndex === null) return;
|
||||
const issueText = issues[activeIssueIndex].text;
|
||||
|
||||
setIssues(currentIssues =>
|
||||
currentIssues.map((issue, index) =>
|
||||
index === activeIssueIndex
|
||||
? { ...issue, status: 'resolved', reason }
|
||||
: issue
|
||||
)
|
||||
);
|
||||
|
||||
onResolve(issueText, reason);
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const handleReopen = (indexToReopen: number) => {
|
||||
setIssues(currentIssues =>
|
||||
currentIssues.map((issue, index) =>
|
||||
index === indexToReopen
|
||||
? { ...issue, status: 'actionable', reason: undefined }
|
||||
: issue
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Determine styles based on status
|
||||
let containerStyles = 'bg-white border-slate-100';
|
||||
let gradientOverlay = 'from-slate-50/50 to-white';
|
||||
let headerColor = 'text-slate-800';
|
||||
let issueIconColor = 'text-slate-400';
|
||||
let issueIcon = <InformationCircleIcon className="h-5 w-5 text-slate-400 mt-0.5 flex-shrink-0" />;
|
||||
|
||||
if (currentStatus === 'Green') {
|
||||
containerStyles = 'bg-white border-emerald-100 hover:border-emerald-200';
|
||||
gradientOverlay = 'from-emerald-50/40 to-white';
|
||||
headerColor = 'text-emerald-900';
|
||||
} else if (currentStatus === 'Amber') {
|
||||
containerStyles = 'bg-white border-amber-100 hover:border-amber-200';
|
||||
gradientOverlay = 'from-amber-50/40 to-white';
|
||||
headerColor = 'text-amber-900';
|
||||
issueIconColor = 'text-amber-500';
|
||||
issueIcon = <ExclamationTriangleIcon className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />;
|
||||
} else if (currentStatus === 'Red') {
|
||||
containerStyles = 'bg-white border-red-100 hover:border-red-200';
|
||||
gradientOverlay = 'from-red-50/40 to-white';
|
||||
headerColor = 'text-red-900';
|
||||
issueIconColor = 'text-red-500';
|
||||
issueIcon = <ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResolveIssueModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSubmit={handleResolve}
|
||||
issueText={activeIssueIndex !== null ? issues[activeIssueIndex].text : ''}
|
||||
/>
|
||||
<div className={`relative rounded-2xl border p-6 shadow-sm transition-all duration-300 hover:shadow-md ${containerStyles} overflow-hidden group`}>
|
||||
{/* Subtle gradient background */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-br ${gradientOverlay} pointer-events-none`}></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-center gap-4 mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className={`text-lg font-bold ${headerColor}`}>{title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<RagStatusBadge status={currentStatus} />
|
||||
<button
|
||||
onClick={onFlag}
|
||||
className="p-1.5 text-slate-400 rounded-lg hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="Flag as incorrect"
|
||||
aria-label="Flag this feedback as incorrect"
|
||||
>
|
||||
<FlagIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-600 text-sm leading-relaxed mb-6">{review.feedback}</p>
|
||||
|
||||
{currentStatus !== 'Error' && issues.length > 0 && (
|
||||
<div className="bg-white/50 rounded-xl border border-slate-100 p-4">
|
||||
<h5 className={`text-xs font-bold uppercase tracking-wider mb-3 ${issueIconColor}`}>Actionable Issues</h5>
|
||||
<ul className="space-y-3">
|
||||
{issues.map((issue, index) => (
|
||||
<li key={index} className="flex items-start gap-3 group/issue">
|
||||
{issue.status === 'actionable' ? (
|
||||
issueIcon
|
||||
) : (
|
||||
<CheckCircleIcon className="h-5 w-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-grow pt-0.5">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
|
||||
<span className={`text-sm ${issue.status === 'resolved' ? 'line-through text-slate-400' : 'text-slate-700'}`}>
|
||||
{issue.text}
|
||||
</span>
|
||||
{issue.status === 'actionable' ? (
|
||||
<button
|
||||
onClick={() => handleOpenModal(index)}
|
||||
className="flex-shrink-0 opacity-0 group-hover/issue:opacity-100 focus:opacity-100 text-xs font-semibold text-brand-accent hover:text-brand-dark-blue bg-brand-light-blue/10 hover:bg-brand-light-blue/20 px-2.5 py-1 rounded-lg transition-all"
|
||||
>
|
||||
Mark Resolved
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative inline-block group/tooltip">
|
||||
<InformationCircleIcon className="h-4 w-4 text-slate-300 cursor-help" />
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 bg-slate-800 text-white text-xs rounded-lg py-2 px-3 opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none z-20 shadow-xl">
|
||||
<span className="font-bold block mb-1">Reason for resolution:</span>
|
||||
"{issue.reason}"
|
||||
</div>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleReopen(index)}
|
||||
className="text-xs font-semibold text-slate-400 hover:text-brand-dark-blue bg-slate-100 hover:bg-slate-200 px-2.5 py-1 rounded-lg transition-all"
|
||||
>
|
||||
Re-open
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{currentStatus !== 'Error' && issues.length === 0 && (
|
||||
<div className="flex items-center p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 text-emerald-700 text-sm">
|
||||
<CheckCircleIcon className="h-5 w-5 mr-2" />
|
||||
<span className="font-semibold">No actionable issues found.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LeadAgentSummary: React.FC<{ status: OverallStatus, summary: string, onFlag: () => void; }> = ({ status, summary, onFlag }) => {
|
||||
const isPassed = status === 'Passed';
|
||||
|
||||
let themeStyles = 'from-sky-50 to-white border-sky-100 text-brand-dark-blue';
|
||||
let iconBg = 'bg-sky-100 text-brand-accent';
|
||||
let icon = <InformationCircleIcon className="h-8 w-8" />;
|
||||
let blobColor = 'bg-sky-400';
|
||||
|
||||
if (status === 'Passed') {
|
||||
themeStyles = 'from-emerald-50 to-white border-emerald-200 text-emerald-900';
|
||||
iconBg = 'bg-emerald-100 text-emerald-600';
|
||||
icon = <CheckCircleIcon className="h-8 w-8" />;
|
||||
blobColor = 'bg-emerald-400';
|
||||
} else if (status === 'Failed') {
|
||||
themeStyles = 'from-rose-50 to-white border-rose-200 text-rose-900';
|
||||
iconBg = 'bg-rose-100 text-rose-600';
|
||||
icon = <ExclamationTriangleIcon className="h-8 w-8" />;
|
||||
blobColor = 'bg-rose-400';
|
||||
}
|
||||
|
||||
if (status === 'Requires Manual Legal Review') return null;
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl border p-8 shadow-lg transition-all duration-500 bg-gradient-to-br ${themeStyles}`}>
|
||||
{/* Abstract decorative blob */}
|
||||
<div className={`absolute -top-12 -right-12 w-48 h-48 rounded-full blur-3xl opacity-20 ${blobColor}`}></div>
|
||||
|
||||
<div className="relative z-10 flex items-start gap-6">
|
||||
<div className={`flex-shrink-0 p-3 rounded-2xl ${iconBg} shadow-sm`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="text-2xl font-extrabold tracking-tight mb-3">
|
||||
Overall Status: <span className="opacity-90">{status}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={onFlag}
|
||||
className="text-slate-400 hover:text-red-500 p-2 rounded-full hover:bg-white/50 transition-colors"
|
||||
title="Flag as incorrect"
|
||||
>
|
||||
<FlagIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-lg leading-relaxed ${isPassed ? 'text-emerald-800/80' : 'text-slate-700'}`}>
|
||||
{summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FinancialPromotionSummary: React.FC<{ reason: string; summary: string }> = ({ reason, summary }) => {
|
||||
const [exportStatus, setExportStatus] = useState('Export for Legal');
|
||||
|
||||
const handleExport = () => {
|
||||
const textToCopy = `## Financial Promotion Review Required ##\n\nReason for Flag:\n${reason}\n\n## AI Agent Summary ##\n${summary}`.trim();
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
setExportStatus('Copied!');
|
||||
setTimeout(() => setExportStatus('Export for Legal'), 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
setExportStatus('Failed');
|
||||
setTimeout(() => setExportStatus('Export for Legal'), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-2xl border border-purple-200 bg-gradient-to-br from-purple-50 to-white p-8 shadow-lg">
|
||||
<div className="absolute -top-20 -right-20 w-64 h-64 rounded-full blur-3xl opacity-20 bg-purple-500"></div>
|
||||
|
||||
<div className="relative z-10 flex items-start gap-6">
|
||||
<div className="flex-shrink-0 p-3 rounded-2xl bg-purple-100 text-purple-600 shadow-sm">
|
||||
<LegalIcon className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<h3 className="text-2xl font-extrabold text-purple-900 tracking-tight mb-2">
|
||||
Financial Promotion Detected
|
||||
</h3>
|
||||
<p className="text-lg text-purple-800/80 mb-6 leading-relaxed">
|
||||
This proof has been identified as a financial promotion and requires a separate, manual review from the Barclays legal team.
|
||||
</p>
|
||||
|
||||
<div className="bg-white/60 backdrop-blur-sm border border-purple-100 rounded-xl p-4 mb-6 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-purple-500 mb-1">Reason Identified</p>
|
||||
<p className="text-purple-900 font-medium italic">"{reason}"</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-2 bg-purple-600 text-white font-bold py-3 px-6 rounded-xl shadow-lg shadow-purple-600/20 hover:bg-purple-700 hover:shadow-purple-600/30 transition-all duration-300"
|
||||
>
|
||||
<ExportIcon className="h-5 w-5" />
|
||||
{exportStatus}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const FeedbackReport: React.FC<{
|
||||
feedback: AgentReview;
|
||||
onFlagSubmit: (agentName: string, comments: string) => void;
|
||||
onResolveSubmit: (agentName: string, issueText: string, reason: string) => void;
|
||||
}> = ({ feedback, onFlagSubmit, onResolveSubmit }) => {
|
||||
const [flagModalState, setFlagModalState] = useState<{ isOpen: boolean; agentName: string }>({
|
||||
isOpen: false,
|
||||
agentName: '',
|
||||
});
|
||||
|
||||
const handleOpenFlagModal = (agentName: string) => {
|
||||
setFlagModalState({ isOpen: true, agentName });
|
||||
};
|
||||
|
||||
const handleCloseFlagModal = () => {
|
||||
setFlagModalState({ isOpen: false, agentName: '' });
|
||||
};
|
||||
|
||||
const handleSubmitFlag = (comments: string) => {
|
||||
onFlagSubmit(flagModalState.agentName, comments);
|
||||
alert(`Thank you for your feedback on the ${flagModalState.agentName}'s review. This has been logged for auditing.`);
|
||||
handleCloseFlagModal();
|
||||
};
|
||||
|
||||
const agentReviews = [
|
||||
{ title: 'Legal Agent', review: feedback.legalAgentReview },
|
||||
{ title: 'Brand Agent', review: feedback.brandAgentReview },
|
||||
{ title: 'Tone Agent', review: feedback.toneAgentReview },
|
||||
{ title: 'Channel Agent', review: feedback.channelAgentReview },
|
||||
];
|
||||
|
||||
const isFinancialPromotion = feedback.overallStatus === 'Requires Manual Legal Review';
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<FlagIssueModal
|
||||
isOpen={flagModalState.isOpen}
|
||||
onClose={handleCloseFlagModal}
|
||||
onSubmit={handleSubmitFlag}
|
||||
agentName={flagModalState.agentName}
|
||||
/>
|
||||
|
||||
{isFinancialPromotion ? (
|
||||
<FinancialPromotionSummary
|
||||
reason={feedback.financialPromotionReason || "No specific reason provided."}
|
||||
summary={feedback.leadAgentSummary}
|
||||
/>
|
||||
) : (
|
||||
<LeadAgentSummary
|
||||
status={feedback.overallStatus}
|
||||
summary={feedback.leadAgentSummary}
|
||||
onFlag={() => handleOpenFlagModal('Lead Agent')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{agentReviews.map(({ title, review }) => (
|
||||
<SubReviewCard
|
||||
key={title}
|
||||
title={`${title}`}
|
||||
review={review}
|
||||
onFlag={() => handleOpenFlagModal(title)}
|
||||
onResolve={(issueText, reason) => onResolveSubmit(title, issueText, reason)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
15
frontend/components/Header.tsx
Normal file
15
frontend/components/Header.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { EagleIcon } from './icons/EagleIcon';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
return (
|
||||
<header className="bg-brand-dark-blue p-4 shadow-md">
|
||||
<div className="max-w-7xl mx-auto flex items-center">
|
||||
<EagleIcon className="h-10 w-10 text-brand-light-blue" />
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white ml-3">
|
||||
AI Proof Reviewer
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
61
frontend/components/Hero.tsx
Normal file
61
frontend/components/Hero.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
|
||||
import React from 'react';
|
||||
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
|
||||
|
||||
interface HeroProps {
|
||||
onGetStarted?: () => void;
|
||||
}
|
||||
|
||||
const ArrowRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Hero: React.FC<HeroProps> = ({ onGetStarted }) => {
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-slate-900 min-h-[500px] flex items-center">
|
||||
{/* Abstract background elements */}
|
||||
<div className="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full bg-brand-accent/20 blur-3xl pointer-events-none"></div>
|
||||
<div className="absolute bottom-[-20%] left-[-10%] w-[500px] h-[500px] rounded-full bg-brand-dark-blue/40 blur-3xl pointer-events-none"></div>
|
||||
|
||||
{/* Grid Pattern Overlay */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 pointer-events-none"></div>
|
||||
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-24 relative z-10 w-full">
|
||||
<div className="max-w-3xl">
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full bg-brand-accent/10 border border-brand-accent/20 text-brand-light-blue text-xs font-bold uppercase tracking-widest mb-6">
|
||||
<span className="w-2 h-2 rounded-full bg-brand-light-blue mr-2 animate-pulse"></span>
|
||||
AI-Powered Compliance
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl font-extrabold text-white leading-tight mb-6">
|
||||
Mod Comms <br/>
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-light-blue to-white">
|
||||
Intelligent Review
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-slate-300 mb-10 leading-relaxed max-w-2xl">
|
||||
Streamline your creative approval process. Mod Comms analyses your assets against guidelines and best practice in seconds, not days.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
onClick={onGetStarted}
|
||||
className="group inline-flex items-center justify-center px-8 py-4 text-base font-bold text-white bg-brand-accent rounded-xl shadow-lg shadow-brand-accent/30 hover:bg-white hover:text-brand-dark-blue transition-all duration-300"
|
||||
>
|
||||
Start Analysis
|
||||
<ArrowRightIcon className="ml-2 h-5 w-5 transform group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center justify-center px-8 py-4 text-base font-bold text-white bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition-all duration-300 backdrop-blur-sm"
|
||||
>
|
||||
View Documentation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
frontend/components/LoadingVisual.tsx
Normal file
25
frontend/components/LoadingVisual.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { BarclaysLogo } from './icons/BarclaysLogo';
|
||||
|
||||
export const LoadingVisual: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-20 px-6 min-h-[600px]">
|
||||
<div className="relative flex items-center justify-center w-48 h-48">
|
||||
{/* Pulsing circles */}
|
||||
<div className="absolute w-full h-full rounded-full bg-brand-accent/20 animate-slow-ping"></div>
|
||||
<div className="absolute w-3/4 h-3/4 rounded-full bg-brand-accent/30 animate-slow-ping [animation-delay:-1s]"></div>
|
||||
|
||||
{/* Central Icon */}
|
||||
<div className="relative bg-white backdrop-blur-sm rounded-full p-6 border border-gray-200 shadow-lg flex items-center justify-center w-28 h-28">
|
||||
<BarclaysLogo className="h-16 w-16 text-brand-accent" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue mt-12">
|
||||
Finalizing your report...
|
||||
</h2>
|
||||
<p className="text-gray-500 mt-2 max-w-md">
|
||||
Our AI agents are completing their analysis. Your detailed feedback summary will appear here shortly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
169
frontend/components/Login.tsx
Normal file
169
frontend/components/Login.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { BarclaysLogo } from './icons/BarclaysLogo';
|
||||
import { XIcon } from './icons/XIcon';
|
||||
import { MicrosoftLogo } from './icons/MicrosoftLogo';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
const SupportModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (query: string) => void;
|
||||
}> = ({ isOpen, onClose, onSubmit }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (query.trim()) {
|
||||
onSubmit(query);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-lg transform transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-xl font-bold text-brand-dark-blue">Contact Support</h3>
|
||||
<button onClick={onClose} className="-mt-2 -mr-2 p-2 rounded-full hover:bg-gray-200 transition-colors">
|
||||
<XIcon className="h-6 w-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<p className="text-gray-600 mb-4">Please describe your issue or query below. A member of our team will be in touch shortly.</p>
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
|
||||
rows={5}
|
||||
placeholder="Type your message here..."
|
||||
required
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
disabled={!query.trim()}
|
||||
>
|
||||
Submit Query
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
||||
const handleSupportSubmit = (query: string) => {
|
||||
console.log("Support query submitted:", query);
|
||||
alert("Thank you for your query. A member of the support team will be in touch with you shortly.");
|
||||
setIsSupportModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMicrosoftLogin = () => {
|
||||
setIsLoggingIn(true);
|
||||
// Simulate redirect/auth delay
|
||||
setTimeout(() => {
|
||||
onLogin();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SupportModal
|
||||
isOpen={isSupportModalOpen}
|
||||
onClose={() => setIsSupportModalOpen(false)}
|
||||
onSubmit={handleSupportSubmit}
|
||||
/>
|
||||
<div className="fixed inset-0 overflow-y-auto bg-[#0f172a] flex items-center justify-center font-sans p-4">
|
||||
{/* Modern Glassy Background */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* Top Left Blob */}
|
||||
<div className="absolute -top-[20%] -left-[10%] w-[70%] h-[70%] rounded-full bg-gradient-to-br from-brand-accent/30 to-purple-600/30 blur-[120px] animate-pulse" style={{ animationDuration: '8s' }}></div>
|
||||
|
||||
{/* Bottom Right Blob */}
|
||||
<div className="absolute -bottom-[20%] -right-[10%] w-[70%] h-[70%] rounded-full bg-gradient-to-tl from-brand-light-blue/30 to-emerald-500/30 blur-[120px] animate-pulse" style={{ animationDuration: '10s', animationDelay: '1s' }}></div>
|
||||
|
||||
{/* Noise Texture */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light"></div>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="relative z-10 bg-white/90 backdrop-blur-xl p-8 sm:p-12 shadow-2xl rounded-3xl w-full max-w-md flex flex-col items-center text-center border border-white/50 ring-1 ring-white/50">
|
||||
<div className="mb-8 transform transition-transform hover:scale-105 duration-300">
|
||||
<BarclaysLogo className="h-12 w-auto text-brand-dark-blue" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-extrabold text-brand-dark-blue mb-2 tracking-tight">Mod Comms</h1>
|
||||
<p className="text-slate-500 mb-8 font-medium">Proof Review & Compliance Platform</p>
|
||||
|
||||
<div className="w-full space-y-6">
|
||||
<div className="p-5 bg-blue-50/80 rounded-xl border border-blue-100 text-left">
|
||||
<p className="text-sm text-blue-900 leading-relaxed">
|
||||
<span className="font-bold block mb-1 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
Enterprise Sign-In
|
||||
</span>
|
||||
This application is connected to Azure Active Directory. Please sign in using your corporate Microsoft account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleMicrosoftLogin}
|
||||
disabled={isLoggingIn}
|
||||
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-50 text-slate-700 font-bold py-4 px-6 rounded-xl border border-gray-200 shadow-lg shadow-gray-200/50 hover:shadow-xl hover:border-gray-300 transition-all duration-300 group disabled:opacity-70 disabled:cursor-wait transform hover:-translate-y-0.5"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<svg className="animate-spin h-5 w-5 text-brand-accent" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<>
|
||||
<MicrosoftLogo className="h-5 w-5" />
|
||||
<span>Sign in with Microsoft</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-6 border-t border-gray-200/60 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSupportModalOpen(true)}
|
||||
className="text-sm text-slate-500 hover:text-brand-accent transition-colors font-medium"
|
||||
>
|
||||
Having trouble signing in? Contact Support
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="absolute bottom-0 left-0 right-0 text-center p-6 z-10">
|
||||
<p className="text-xs text-slate-400/80 font-medium">© {new Date().getFullYear()} OLIVER Agency Mod Comms. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
150
frontend/components/PDFReport.tsx
Normal file
150
frontend/components/PDFReport.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import React from 'react';
|
||||
import type { AgentReview, SubReview, RagStatus, OverallStatus } from '../types';
|
||||
import { BarclaysLogo } from './icons/BarclaysLogo';
|
||||
import { OliverLogo } from './icons/OliverLogo';
|
||||
import { LegalIcon } from './icons/LegalIcon';
|
||||
import { BrandIcon } from './icons/BrandIcon';
|
||||
import { CopyIcon } from './icons/CopyIcon';
|
||||
import { ChannelIcon } from './icons/ChannelIcon';
|
||||
|
||||
interface PDFReportProps {
|
||||
campaignName: string;
|
||||
proofs: any[];
|
||||
}
|
||||
|
||||
const RagStatusBadge: React.FC<{ status: RagStatus }> = ({ status }) => {
|
||||
let bgColor = '#E5E7EB'; // Gray for Error
|
||||
let textColor = '#1F2937';
|
||||
|
||||
switch (status) {
|
||||
case 'Red': bgColor = '#FEE2E2'; textColor = '#991B1B'; break;
|
||||
case 'Amber': bgColor = '#FEF3C7'; textColor = '#92400E'; break;
|
||||
case 'Green': bgColor = '#D1FAE5'; textColor = '#065F46'; break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ backgroundColor: bgColor, color: textColor, padding: '4px 12px', borderRadius: '9999px', fontSize: '12px', fontWeight: 'bold' }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const PDFReport: React.FC<PDFReportProps> = ({ campaignName, proofs }) => {
|
||||
const today = new Date().toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const isCampaignReport = proofs.length > 1;
|
||||
const singleProofName = !isCampaignReport && proofs.length > 0 ? proofs[0].proofName : '';
|
||||
|
||||
return (
|
||||
<div style={{ width: '210mm', fontFamily: 'Arial, sans-serif', color: '#333333', background: '#FFFFFF' }}>
|
||||
{/* --- Cover Page --- */}
|
||||
<div style={{ width: '210mm', height: '297mm', display: 'flex', flexDirection: 'column', padding: '20mm', boxSizing: 'border-box', borderBottom: '1px solid #e5e5e5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '10mm', borderBottom: '1px solid #e5e5e5' }}>
|
||||
<BarclaysLogo style={{ height: '50px', width: 'auto', color: '#001f5a' }} />
|
||||
<OliverLogo style={{ height: '25px', width: 'auto' }} />
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '42px', color: '#001f5a', margin: '0 0 10px 0' }}>AI Compliance & Brand Report</h1>
|
||||
<p style={{ fontSize: '18px', color: '#555', marginTop: '0' }}>
|
||||
{isCampaignReport ? 'Campaign-Level Summary' : 'Single Proof Analysis'}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid #e5e5e5', paddingTop: '10mm' }}>
|
||||
<p style={{ margin: 0, fontSize: '14px' }}><strong>Campaign:</strong> {campaignName}</p>
|
||||
{!isCampaignReport && <p style={{ margin: '5px 0 0 0', fontSize: '14px' }}><strong>Proof:</strong> {singleProofName}</p>}
|
||||
<p style={{ margin: '5px 0 0 0', fontSize: '14px' }}><strong>Export Date:</strong> {today}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Table of Contents for Campaign Report --- */}
|
||||
{isCampaignReport && (
|
||||
<div style={{ width: '210mm', minHeight: '297mm', padding: '20mm', boxSizing: 'border-box', pageBreakBefore: 'always', borderBottom: '1px solid #e5e5e5' }}>
|
||||
<h2 style={{ fontSize: '28px', color: '#001f5a', borderBottom: '2px solid #00a3e0', paddingBottom: '8px', marginBottom: '20px' }}>
|
||||
Table of Contents
|
||||
</h2>
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{proofs.map((proof, index) => (
|
||||
<li key={index} style={{ fontSize: '16px', padding: '10px 0', borderBottom: '1px dotted #ccc' }}>
|
||||
{proof.proofName} - V{proof.versions[0]?.version || 1}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* --- Proof Report Pages --- */}
|
||||
{proofs.map((proof) => {
|
||||
const version = proof.versions[0];
|
||||
if (!version) return null;
|
||||
const feedback: AgentReview = version.feedback;
|
||||
|
||||
const agentReviews = [
|
||||
{ title: 'Legal Agent', review: feedback.legalAgentReview, icon: <LegalIcon style={{height: '24px', width: '24px'}} /> },
|
||||
{ title: 'Brand Agent', review: feedback.brandAgentReview, icon: <BrandIcon style={{height: '24px', width: '24px'}} /> },
|
||||
{ title: 'Tone Agent', review: feedback.toneAgentReview, icon: <CopyIcon style={{height: '24px', width: '24px'}} /> },
|
||||
{ title: 'Channel Agent', review: feedback.channelAgentReview, icon: <ChannelIcon style={{height: '24px', width: '24px'}} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={proof.proofName} style={{ width: '210mm', minHeight: '297mm', padding: '15mm', boxSizing: 'border-box', pageBreakBefore: 'always' }}>
|
||||
|
||||
{/* Proof Header */}
|
||||
<div style={{ paddingBottom: '8px', borderBottom: '2px solid #00a3e0', marginBottom: '10mm' }}>
|
||||
<h2 style={{ fontSize: '24px', color: '#001f5a', margin: 0 }}>{proof.proofName} - V{version.version}</h2>
|
||||
<p style={{ fontSize: '14px', color: '#555', margin: '4px 0 0 0' }}>
|
||||
{version.workfrontId} • {proof.channel} / {proof.subChannel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview & Summary */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10mm', marginBottom: '10mm' }}>
|
||||
<div style={{ border: '1px solid #ccc', padding: '4px', background: '#f8f8f8' }}>
|
||||
<img src={version.proofPreviewUrl} alt="Proof Preview" style={{ width: '100%', objectFit: 'contain' }} />
|
||||
</div>
|
||||
<div style={{ backgroundColor: '#f4f6f8', padding: '6mm', borderRadius: '4px', borderLeft: `4px solid ${feedback.overallStatus === 'Passed' ? '#10B981' : '#EF4444'}`}}>
|
||||
<h3 style={{ fontSize: '18px', margin: '0 0 8px 0', color: '#001f5a' }}>Overall Summary</h3>
|
||||
<p style={{ fontSize: '14px', margin: '0 0 10px 0', fontWeight: 'bold' }}>Status: {feedback.overallStatus}</p>
|
||||
{feedback.overallStatus === 'Requires Manual Legal Review' && (
|
||||
<div style={{ backgroundColor: '#F3E8FF', border: '1px solid #D8B4FE', borderRadius: '4px', padding: '8px', marginBottom: '10px'}}>
|
||||
<p style={{fontSize: '13px', margin: 0, fontWeight: 'bold', color: '#6B21A8'}}>Financial Promotion Detected:</p>
|
||||
<p style={{fontSize: '12px', margin: '4px 0 0 0', color: '#6B21A8', fontStyle: 'italic'}}>"{feedback.financialPromotionReason}"</p>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ fontSize: '14px', lineHeight: 1.5, margin: 0 }}>{feedback.leadAgentSummary}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Agent Feedback */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8mm' }}>
|
||||
{agentReviews.map(({ title, review, icon }) => (
|
||||
<div key={title} style={{ border: '1px solid #e5e5e5', borderRadius: '4px', padding: '5mm', backgroundColor: '#fff', breakInside: 'avoid' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<h4 style={{ fontSize: '16px', fontWeight: 'bold', color: '#001f5a', margin: 0, display: 'flex', alignItems: 'center', gap: '8px'}}>
|
||||
<span style={{color: '#0070c0'}}>{icon}</span> {title}
|
||||
</h4>
|
||||
<RagStatusBadge status={review.ragStatus} />
|
||||
</div>
|
||||
<p style={{ fontSize: '13px', lineHeight: 1.5, margin: '0 0 10px 0', borderTop: '1px solid #eee', paddingTop: '10px' }}>{review.feedback}</p>
|
||||
{review.issues && review.issues.length > 0 && (
|
||||
<div>
|
||||
<h5 style={{ fontSize: '13px', fontWeight: 'bold', margin: '0 0 5px 0' }}>Actionable Issues:</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '18px', fontSize: '12px', lineHeight: 1.5 }}>
|
||||
{review.issues.map((issue, i) => <li key={i} style={{ marginBottom: '4px' }}>{issue}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
105
frontend/components/Profile.tsx
Normal file
105
frontend/components/Profile.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
import { LogoutIcon } from './icons/LogoutIcon';
|
||||
import { QuestionMarkIcon } from './icons/QuestionMarkIcon';
|
||||
|
||||
interface ProfileProps {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const Profile: React.FC<ProfileProps> = ({ onLogout }) => {
|
||||
const [isQuestionFormVisible, setIsQuestionFormVisible] = useState(false);
|
||||
const [question, setQuestion] = useState('');
|
||||
|
||||
const userDetails = {
|
||||
'Account Type': 'Administrator',
|
||||
'First Name': 'Steve',
|
||||
'Last Name': 'O\'Donoghue',
|
||||
'Email': 'steveodonoghue@oliver.agency',
|
||||
'Entity': 'OLIVER Agency',
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
};
|
||||
|
||||
const handleToggleQuestionForm = () => {
|
||||
setIsQuestionFormVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const handleSubmitQuestion = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!question.trim()) {
|
||||
alert('Please enter a question before submitting.');
|
||||
return;
|
||||
}
|
||||
alert(`Your question has been submitted:\n\n"${question}"\n\nWe'll get back to you shortly.`);
|
||||
setQuestion('');
|
||||
setIsQuestionFormVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Your Profile</h1>
|
||||
<p className="text-base lg:text-lg text-gray-600 mt-1">View your account details and manage settings.</p>
|
||||
</header>
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<section className="bg-white rounded-lg shadow-md p-6 sm:p-8 border border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue mb-6">Account Information</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5">
|
||||
{Object.entries(userDetails).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-sm font-semibold text-gray-500 tracking-wide uppercase">{key}</p>
|
||||
<p className="text-lg text-gray-800 mt-1">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 mt-8 pt-6 flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center justify-center gap-2 bg-red-600 text-white font-semibold py-2 px-4 rounded-md hover:bg-red-700 transition-colors duration-300"
|
||||
>
|
||||
<LogoutIcon className="h-5 w-5" />
|
||||
Logout
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleQuestionForm}
|
||||
className="flex items-center justify-center gap-2 bg-gray-200 text-gray-800 font-semibold py-2 px-4 rounded-md hover:bg-gray-300 transition-colors duration-300"
|
||||
>
|
||||
<QuestionMarkIcon className="h-5 w-5" />
|
||||
Got a question?
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isQuestionFormVisible && (
|
||||
<section className="mt-8 bg-white rounded-lg shadow-md p-6 sm:p-8 border border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-brand-dark-blue mb-4">Ask a Question</h2>
|
||||
<form onSubmit={handleSubmitQuestion}>
|
||||
<p className="text-gray-600 mb-4">Your question will be sent to the OLIVER Agency support team.</p>
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
|
||||
placeholder="Type your question here..."
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-5 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
disabled={!question.trim()}
|
||||
>
|
||||
Submit Question
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1049
frontend/components/Projects.tsx
Normal file
1049
frontend/components/Projects.tsx
Normal file
File diff suppressed because it is too large
Load diff
84
frontend/components/ProofPreview.tsx
Normal file
84
frontend/components/ProofPreview.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import { DocumentIcon } from './icons/DocumentIcon';
|
||||
|
||||
interface ProofPreviewProps {
|
||||
file?: File | null;
|
||||
previewUrl: string | null;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const ProofPreview: React.FC<ProofPreviewProps> = ({ file, previewUrl, fileName }) => {
|
||||
if (!previewUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getMimeType = (): string => {
|
||||
if (file?.type) return file.type;
|
||||
if (previewUrl.startsWith('data:')) {
|
||||
const match = previewUrl.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);/);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return 'application/octet-stream'; // Fallback
|
||||
};
|
||||
|
||||
const fileType = getMimeType();
|
||||
const displayName = fileName || file?.name || 'Proof Preview';
|
||||
|
||||
|
||||
const renderPreview = () => {
|
||||
if (fileType.startsWith('image/')) {
|
||||
return (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={displayName}
|
||||
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
|
||||
style={{ maxHeight: 'calc(100vh - 9rem)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'video/mp4') {
|
||||
return (
|
||||
<video
|
||||
src={previewUrl}
|
||||
controls
|
||||
className="w-full rounded-lg shadow-2xl object-contain border border-gray-200 bg-white p-2"
|
||||
style={{ maxHeight: 'calc(100vh - 9rem)' }}
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === 'application/pdf') {
|
||||
return (
|
||||
<iframe
|
||||
src={`${previewUrl}#view=fitH`}
|
||||
title={displayName}
|
||||
className="w-full h-[calc(100vh-9rem)] rounded-lg shadow-2xl border border-gray-200 bg-white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for other file types
|
||||
return (
|
||||
<div
|
||||
className="w-full rounded-lg shadow-2xl border border-gray-200 bg-white p-8 flex flex-col items-center justify-center text-center"
|
||||
style={{ minHeight: '300px', maxHeight: 'calc(100vh - 9rem)' }}
|
||||
>
|
||||
<DocumentIcon className="h-20 w-20 text-gray-400 mb-4" />
|
||||
<p className="text-lg font-semibold text-brand-dark-blue break-all">{displayName}</p>
|
||||
<p className="text-sm text-gray-500">{fileType}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">No preview available for this file type.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticky top-8">
|
||||
{renderPreview()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
113
frontend/components/ProofTypeManager.tsx
Normal file
113
frontend/components/ProofTypeManager.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { TrashIcon } from './icons/TrashIcon';
|
||||
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
||||
|
||||
interface ProofTypeManagerProps {
|
||||
subChannels: string[];
|
||||
proofTypesBySubChannel: Record<string, string[]>;
|
||||
onAddProofType: (subChannel: string, value: string) => void;
|
||||
onRemoveProofType: (subChannel: string, value: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const ProofTypeManager: React.FC<ProofTypeManagerProps> = ({
|
||||
subChannels,
|
||||
proofTypesBySubChannel,
|
||||
onAddProofType,
|
||||
onRemoveProofType,
|
||||
disabled
|
||||
}) => {
|
||||
const [selectedSubChannel, setSelectedSubChannel] = useState<string>('');
|
||||
const [newItem, setNewItem] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// If subChannels are available, default to the first one.
|
||||
if (subChannels.length > 0 && !selectedSubChannel) {
|
||||
setSelectedSubChannel(subChannels[0]);
|
||||
}
|
||||
}, [subChannels, selectedSubChannel]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newItem.trim() && selectedSubChannel) {
|
||||
onAddProofType(selectedSubChannel, newItem.trim());
|
||||
setNewItem('');
|
||||
}
|
||||
};
|
||||
|
||||
const currentProofTypes = proofTypesBySubChannel[selectedSubChannel] || [];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200 flex flex-col h-full">
|
||||
<h2 className="text-xl font-bold text-brand-dark-blue mb-2">Proof Type Manager</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">Assign and manage specific proof types for each sub-channel.</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="subchannel-select" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select a Sub-Channel to manage:
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
id="subchannel-select"
|
||||
value={selectedSubChannel}
|
||||
onChange={(e) => setSelectedSubChannel(e.target.value)}
|
||||
className="w-full bg-white border border-gray-300 rounded-md py-2 pl-3 pr-10 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
|
||||
disabled={disabled}
|
||||
>
|
||||
{subChannels.map(sc => (
|
||||
<option key={sc} value={sc}>{sc}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3 text-gray-400">
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
placeholder="New Proof Type..."
|
||||
className="flex-grow p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
disabled={disabled || !selectedSubChannel}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
disabled={!newItem.trim() || disabled || !selectedSubChannel}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
|
||||
{currentProofTypes.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{currentProofTypes.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-center justify-between bg-gray-50 p-2.5 rounded-md border border-gray-200 group"
|
||||
>
|
||||
<span className="text-gray-800">{item}</span>
|
||||
<button
|
||||
onClick={() => onRemoveProofType(selectedSubChannel, item)}
|
||||
className={`text-gray-400 hover:text-red-600 transition-opacity ${disabled ? 'opacity-0 cursor-not-allowed' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
title={`Remove ${item}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-md h-full flex items-center justify-center">
|
||||
<p>No proof types configured for <br/> <strong className="text-gray-600">{selectedSubChannel}</strong>.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
498
frontend/components/Settings.tsx
Normal file
498
frontend/components/Settings.tsx
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { DropdownOptions } from '../App';
|
||||
import { TrashIcon } from './icons/TrashIcon';
|
||||
import { UserIcon } from './icons/UserIcon';
|
||||
import { PlusIcon } from './icons/PlusIcon';
|
||||
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
||||
import { ClipboardIcon } from './icons/ClipboardIcon';
|
||||
import { CopyGenAIIcon } from './icons/CopyGenAIIcon';
|
||||
|
||||
interface ManagementCardProps {
|
||||
title: string;
|
||||
items: string[];
|
||||
onAdd: (item: string) => void;
|
||||
onRemove: (item: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const ManagementCard: React.FC<ManagementCardProps> = ({ title, items, onAdd, onRemove, disabled = false, placeholder }) => {
|
||||
const [newItem, setNewItem] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newItem.trim()) {
|
||||
onAdd(newItem.trim());
|
||||
setNewItem('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200 flex flex-col h-full">
|
||||
<h2 className="text-xl font-bold text-brand-dark-blue mb-4">{title}</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
placeholder={placeholder || `New ${title.slice(0, -1)}...`}
|
||||
className="flex-grow p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors duration-300 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
disabled={!newItem.trim() || disabled}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
|
||||
{items.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-center justify-between bg-gray-50 p-2.5 rounded-md border border-gray-200 group hover:border-brand-accent/30 transition-colors"
|
||||
>
|
||||
<span className="text-gray-800">{item}</span>
|
||||
<button
|
||||
onClick={() => onRemove(item)}
|
||||
className={`text-gray-400 hover:text-red-600 transition-opacity ${disabled ? 'opacity-0 cursor-not-allowed' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
title={`Remove ${item}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-md">
|
||||
No items found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- USER MANAGEMENT ---
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
agency: string;
|
||||
}
|
||||
|
||||
const INITIAL_USERS: User[] = [
|
||||
{ id: '1', name: "Steve O'Donoghue", email: "steveodonoghue@oliver.agency", agency: "OLIVER Agency" },
|
||||
{ id: '2', name: "Jane Doe", email: "jane.doe@barclays.com", agency: "Barclays" },
|
||||
{ id: '3', name: "Sarah Jenkins", email: "sarah.jenkins@mindshare.com", agency: "Mindshare" },
|
||||
];
|
||||
|
||||
const AGENCIES = ["OLIVER Agency", "Barclays", "Mindshare", "Zenith", "Unassigned"];
|
||||
|
||||
const UsersTab: React.FC = () => {
|
||||
const [users, setUsers] = useState<User[]>(() => {
|
||||
const saved = localStorage.getItem('barclays_modcomms_users');
|
||||
return saved ? JSON.parse(saved) : INITIAL_USERS;
|
||||
});
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newEmail, setNewEmail] = useState('');
|
||||
const [newAgency, setNewAgency] = useState(AGENCIES[0]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('barclays_modcomms_users', JSON.stringify(users));
|
||||
}, [users]);
|
||||
|
||||
const handleAddUser = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newName && newEmail) {
|
||||
const newUser: User = {
|
||||
id: Date.now().toString(),
|
||||
name: newName,
|
||||
email: newEmail,
|
||||
agency: newAgency
|
||||
};
|
||||
setUsers([...users, newUser]);
|
||||
setNewName('');
|
||||
setNewEmail('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgencyChange = (userId: string, newAgency: string) => {
|
||||
setUsers(users.map(u => u.id === userId ? { ...u, agency: newAgency } : u));
|
||||
};
|
||||
|
||||
const handleDeleteUser = (userId: string) => {
|
||||
if (window.confirm('Are you sure you want to remove this user?')) {
|
||||
setUsers(users.filter(u => u.id !== userId));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Add User Section */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<h3 className="text-lg font-bold text-brand-dark-blue mb-4 flex items-center gap-2">
|
||||
<PlusIcon className="h-5 w-5 text-brand-accent" />
|
||||
Add New User
|
||||
</h3>
|
||||
<form onSubmit={handleAddUser} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent"
|
||||
placeholder="e.g. John Smith"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Agency</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={newAgency}
|
||||
onChange={(e) => setNewAgency(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent focus:border-brand-accent appearance-none"
|
||||
>
|
||||
{AGENCIES.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-brand-accent text-white font-semibold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors"
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden border border-gray-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">Assigned Agency</th>
|
||||
<th scope="col" className="relative px-6 py-3"><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-brand-light-blue/20 flex items-center justify-center text-brand-accent">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{user.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{user.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="relative max-w-xs">
|
||||
<select
|
||||
value={user.agency}
|
||||
onChange={(e) => handleAgencyChange(user.id, e.target.value)}
|
||||
className="w-full bg-white border border-gray-300 text-gray-700 py-1.5 pl-3 pr-8 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
|
||||
>
|
||||
{AGENCIES.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-gray-500 pointer-events-none" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id)}
|
||||
className="text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Remove User"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- SETTINGS COMPONENT ---
|
||||
|
||||
interface SettingsProps {
|
||||
options: DropdownOptions;
|
||||
onAddCampaign: (value: string) => void;
|
||||
onRemoveCampaign: (value: string) => void;
|
||||
onAddChannel: (value: string) => void;
|
||||
onRemoveChannel: (value: string) => void;
|
||||
onAddSubChannel: (channel: string, value: string) => void;
|
||||
onRemoveSubChannel: (channel: string, value: string) => void;
|
||||
onAddProofType: (channel: string, subChannel: string, value: string) => void;
|
||||
onRemoveProofType: (channel: string, subChannel: string, value: string) => void;
|
||||
onNavigate: (view: string) => void;
|
||||
}
|
||||
|
||||
type Tab = 'Campaigns' | 'Channels' | 'SubChannels' | 'ProofTypes' | 'Users' | 'Beta';
|
||||
|
||||
export const Settings: React.FC<SettingsProps> = ({
|
||||
options,
|
||||
onAddCampaign,
|
||||
onRemoveCampaign,
|
||||
onAddChannel,
|
||||
onRemoveChannel,
|
||||
onAddSubChannel,
|
||||
onRemoveSubChannel,
|
||||
onAddProofType,
|
||||
onRemoveProofType,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('Channels');
|
||||
const [selectedChannel, setSelectedChannel] = useState<string>('');
|
||||
const [selectedSubChannel, setSelectedSubChannel] = useState<string>('');
|
||||
|
||||
// Update selected channel if it disappears
|
||||
useEffect(() => {
|
||||
if (selectedChannel && !options.channels[selectedChannel]) {
|
||||
setSelectedChannel('');
|
||||
setSelectedSubChannel('');
|
||||
}
|
||||
}, [options.channels, selectedChannel]);
|
||||
|
||||
// Update selected sub-channel if it disappears
|
||||
useEffect(() => {
|
||||
if (selectedChannel && selectedSubChannel) {
|
||||
const subChannels = options.channels[selectedChannel] || {};
|
||||
if (!subChannels[selectedSubChannel]) {
|
||||
setSelectedSubChannel('');
|
||||
}
|
||||
}
|
||||
}, [options.channels, selectedChannel, selectedSubChannel]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8 h-full bg-brand-gray flex flex-col">
|
||||
<header className="mb-6 flex-shrink-0">
|
||||
<h1 className="text-3xl lg:text-4xl font-bold text-brand-dark-blue">Settings</h1>
|
||||
<p className="text-base lg:text-lg text-gray-600 mt-1">
|
||||
Configure application defaults and user access.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Tabs Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 rounded-t-lg shadow-sm flex-shrink-0">
|
||||
<nav className="-mb-px flex space-x-8 overflow-x-auto" aria-label="Tabs">
|
||||
{[
|
||||
{ id: 'Campaigns', label: 'Campaigns' },
|
||||
{ id: 'Channels', label: 'Channels' },
|
||||
{ id: 'SubChannels', label: 'Sub-channels' },
|
||||
{ id: 'ProofTypes', label: 'Proof Types' },
|
||||
{ id: 'Users', label: 'Users' },
|
||||
{ id: 'Beta', label: '[Beta]' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as Tab)}
|
||||
className={`
|
||||
whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-brand-accent text-brand-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
aria-current={activeTab === tab.id ? 'page' : undefined}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tabs Content */}
|
||||
<div className="flex-1 bg-transparent pt-6 overflow-y-auto">
|
||||
{activeTab === 'Campaigns' && (
|
||||
<div className="max-w-3xl">
|
||||
<ManagementCard
|
||||
title="Campaigns"
|
||||
items={options.campaigns}
|
||||
onAdd={onAddCampaign}
|
||||
onRemove={onRemoveCampaign}
|
||||
placeholder="e.g. Q4 Marketing"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Channels' && (
|
||||
<div className="max-w-3xl">
|
||||
<ManagementCard
|
||||
title="Channels"
|
||||
items={Object.keys(options.channels)}
|
||||
onAdd={onAddChannel}
|
||||
onRemove={onRemoveChannel}
|
||||
placeholder="e.g. Social, OOH"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'SubChannels' && (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Select Parent Channel</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => setSelectedChannel(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none"
|
||||
>
|
||||
<option value="" disabled>Select Channel</option>
|
||||
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManagementCard
|
||||
title={selectedChannel ? `Sub-Channels for ${selectedChannel}` : "Sub-Channels"}
|
||||
items={selectedChannel ? Object.keys(options.channels[selectedChannel]) : []}
|
||||
onAdd={(val) => onAddSubChannel(selectedChannel, val)}
|
||||
onRemove={(val) => onRemoveSubChannel(selectedChannel, val)}
|
||||
disabled={!selectedChannel}
|
||||
placeholder="e.g. Meta, Video"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ProofTypes' && (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Select Channel</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => {
|
||||
setSelectedChannel(e.target.value);
|
||||
setSelectedSubChannel('');
|
||||
}}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none"
|
||||
>
|
||||
<option value="" disabled>Select Channel</option>
|
||||
{Object.keys(options.channels).map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Select Sub-Channel</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedSubChannel}
|
||||
onChange={(e) => setSelectedSubChannel(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
|
||||
disabled={!selectedChannel}
|
||||
>
|
||||
<option value="" disabled>Select Sub-Channel</option>
|
||||
{selectedChannel && Object.keys(options.channels[selectedChannel]).map(sc => (
|
||||
<option key={sc} value={sc}>{sc}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManagementCard
|
||||
title={selectedSubChannel ? `Proof Types for ${selectedSubChannel}` : "Proof Types"}
|
||||
items={
|
||||
(selectedChannel && selectedSubChannel)
|
||||
? (options.channels[selectedChannel][selectedSubChannel] || [])
|
||||
: []
|
||||
}
|
||||
onAdd={(val) => onAddProofType(selectedChannel, selectedSubChannel, val)}
|
||||
onRemove={(val) => onRemoveProofType(selectedChannel, selectedSubChannel, val)}
|
||||
disabled={!selectedChannel || !selectedSubChannel}
|
||||
placeholder="e.g. In-feed 1x1, 300x600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Users' && (
|
||||
<UsersTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'Beta' && (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<h2 className="text-xl font-bold text-brand-dark-blue mb-2 flex items-center gap-2">
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-accent opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-brand-accent"></span>
|
||||
</span>
|
||||
Beta Features
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Explore experimental features currently in development. These tools are functional but may change over time.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => onNavigate('WIP Reviewer')}
|
||||
className="flex flex-col items-start p-4 rounded-lg border border-gray-200 hover:border-brand-accent hover:bg-blue-50/50 transition-all duration-300 group text-left"
|
||||
>
|
||||
<div className="p-2 rounded-md bg-brand-light-blue/10 text-brand-accent mb-3 group-hover:scale-110 transition-transform">
|
||||
<ClipboardIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="font-bold text-brand-dark-blue mb-1">WIP Reviewer</h3>
|
||||
<p className="text-sm text-gray-500">Early-stage feedback on assets before they are finalized.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onNavigate('CopyGenAI')}
|
||||
className="flex flex-col items-start p-4 rounded-lg border border-gray-200 hover:border-brand-accent hover:bg-blue-50/50 transition-all duration-300 group text-left"
|
||||
>
|
||||
<div className="p-2 rounded-md bg-purple-100 text-purple-600 mb-3 group-hover:scale-110 transition-transform">
|
||||
<CopyGenAIIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="font-bold text-brand-dark-blue mb-1">CopyGenAI</h3>
|
||||
<p className="text-sm text-gray-500">Generate and refine marketing copy with AI assistance.</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
frontend/components/Sidebar.tsx
Normal file
94
frontend/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
|
||||
import React from 'react';
|
||||
import { BarclaysLogo } from './icons/BarclaysLogo';
|
||||
import { DashboardIcon } from './icons/DashboardIcon';
|
||||
import { AnalyticsIcon } from './icons/AnalyticsIcon';
|
||||
import { SettingsIcon } from './icons/SettingsIcon';
|
||||
import { UserIcon } from './icons/UserIcon';
|
||||
import { CampaignsIcon } from './icons/CampaignsIcon';
|
||||
import { AuditIcon } from './icons/AuditIcon';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Home', icon: DashboardIcon },
|
||||
{ name: 'Campaigns', icon: CampaignsIcon },
|
||||
// { name: 'WIP Reviewer', icon: ClipboardIcon }, // Hidden: Moved to Settings > Beta
|
||||
// { name: 'CopyGenAI', icon: CopyGenAIIcon }, // Hidden: Moved to Settings > Beta
|
||||
{ name: 'Analytics', icon: AnalyticsIcon },
|
||||
{ name: 'Auditing', icon: AuditIcon },
|
||||
{ name: 'Settings', icon: SettingsIcon },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
activeItem: string;
|
||||
onNavigate: (viewName: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate }) => {
|
||||
return (
|
||||
<aside className="w-72 flex-shrink-0 bg-[#0f172a] text-slate-200 flex flex-col border-r border-slate-800 font-sans">
|
||||
{/* Brand Header */}
|
||||
<div className="h-24 flex items-center px-8 border-b border-slate-800/60">
|
||||
<BarclaysLogo className="h-8 w-auto text-white" />
|
||||
<div className="ml-3 flex flex-col">
|
||||
<span className="text-lg font-bold tracking-tight text-white leading-tight">
|
||||
Mod Comms
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
|
||||
Compliance AI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.name === activeItem;
|
||||
const isComingSoon = (item as any).isComingSoon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() => !isComingSoon && onNavigate(item.name)}
|
||||
className={`group w-full flex items-center px-4 py-3.5 text-left text-sm font-medium rounded-xl transition-all duration-300 ease-in-out ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-brand-accent to-brand-light-blue text-white shadow-lg shadow-brand-accent/20'
|
||||
: isComingSoon
|
||||
? 'text-slate-600 cursor-not-allowed'
|
||||
: 'text-slate-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
disabled={isComingSoon}
|
||||
>
|
||||
<Icon className={`h-5 w-5 mr-4 transition-transform duration-300 ${isActive ? 'scale-110' : 'group-hover:scale-110'}`} />
|
||||
<span className="flex-1 tracking-wide">{item.name}</span>
|
||||
{isActive && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-glow"></div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User Profile Snippet */}
|
||||
<div className="p-4 border-t border-slate-800/60 bg-[#0b1120]">
|
||||
<button
|
||||
onClick={() => onNavigate('Profile')}
|
||||
className={`group w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all duration-200 ${
|
||||
activeItem === 'Profile'
|
||||
? 'bg-slate-800'
|
||||
: 'hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand-dark-blue to-brand-accent flex items-center justify-center ring-2 ring-white/10 group-hover:ring-brand-light-blue/50 transition-all">
|
||||
<UserIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="overflow-hidden">
|
||||
<p className="font-semibold text-sm text-white truncate">Steve O'Donoghue</p>
|
||||
<p className="text-xs text-slate-400 truncate">OLIVER Agency</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
127
frontend/components/StatusDashboard.tsx
Normal file
127
frontend/components/StatusDashboard.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React from 'react';
|
||||
import type { ReviewStatus, AgentName, AgentStatus } from '../types';
|
||||
import { AGENT_NAMES } from '../constants';
|
||||
import { LegalIcon } from './icons/LegalIcon';
|
||||
import { BrandIcon } from './icons/BrandIcon';
|
||||
import { CopyIcon } from './icons/CopyIcon';
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } from './icons/StatusIcons';
|
||||
import { ChannelIcon } from './icons/ChannelIcon';
|
||||
import { BestPracticeIcon } from './icons/BestPracticeIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
interface StatusDashboardProps {
|
||||
status: ReviewStatus;
|
||||
variant?: 'default' | 'hero';
|
||||
}
|
||||
|
||||
interface StatusInfoProps {
|
||||
status: string;
|
||||
isHeroVariant?: boolean;
|
||||
}
|
||||
|
||||
// FIX: Storing components instead of instantiated elements to avoid React.cloneElement type issues.
|
||||
// Fix: Removed 'Best Practice' as it does not exist on AgentName type.
|
||||
const agentIcons: Record<AgentName, React.FC<React.SVGProps<SVGSVGElement>>> = {
|
||||
'Legal Agent': LegalIcon,
|
||||
'Brand Agent': BrandIcon,
|
||||
'Tone Agent': CopyIcon,
|
||||
'Channel Agent': ChannelIcon,
|
||||
};
|
||||
|
||||
const StatusInfo: React.FC<StatusInfoProps> = ({ status, isHeroVariant = false }) => {
|
||||
const colors = isHeroVariant ? {
|
||||
complete: 'text-green-300',
|
||||
issues: 'text-yellow-300',
|
||||
error: 'text-red-300',
|
||||
pending: 'text-gray-400'
|
||||
} : {
|
||||
complete: 'text-green-600',
|
||||
issues: 'text-yellow-600',
|
||||
error: 'text-red-600',
|
||||
pending: 'text-gray-500'
|
||||
};
|
||||
|
||||
switch (status) {
|
||||
case 'complete':
|
||||
return <div className={`flex items-center ${colors.complete}`}><CheckCircleIcon className="h-5 w-5 mr-2" /> Pass</div>;
|
||||
case 'issues-found':
|
||||
return <div className={`flex items-center ${colors.issues}`}><ExclamationTriangleIcon className="h-5 w-5 mr-2" /> Issues Found</div>;
|
||||
case 'error':
|
||||
return <div className={`flex items-center ${colors.error}`}><InformationCircleIcon className="h-5 w-5 mr-2" /> Error</div>;
|
||||
default:
|
||||
return <div className={`flex items-center ${colors.pending}`}><InformationCircleIcon className="h-5 w-5 mr-2" /> Pending</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export const StatusDashboard: React.FC<StatusDashboardProps> = ({ status, variant = 'default' }) => {
|
||||
if (Object.keys(status).length === 0) return null;
|
||||
|
||||
const isHeroVariant = variant === 'hero';
|
||||
|
||||
const getTileClassName = (agentStatus: AgentStatus, isHero: boolean): string => {
|
||||
const baseClasses = 'rounded-lg flex flex-col text-center transition-colors duration-500 ease-in-out';
|
||||
|
||||
if (isHero) {
|
||||
const heroBase = `${baseClasses} p-4`;
|
||||
switch (agentStatus) {
|
||||
case 'complete':
|
||||
return `${heroBase} bg-green-400/20`;
|
||||
case 'issues-found':
|
||||
return `${heroBase} bg-yellow-400/20`;
|
||||
case 'error':
|
||||
return `${heroBase} bg-red-400/20`;
|
||||
default: // 'pending' or 'in-progress'
|
||||
return `${heroBase} bg-white/10`;
|
||||
}
|
||||
} else {
|
||||
const defaultBase = `${baseClasses} p-6 shadow-lg border`;
|
||||
switch (agentStatus) {
|
||||
case 'complete':
|
||||
return `${defaultBase} bg-green-50 border-green-200`;
|
||||
case 'issues-found':
|
||||
return `${defaultBase} bg-yellow-50 border-yellow-200`;
|
||||
case 'error':
|
||||
return `${defaultBase} bg-red-50 border-red-200`;
|
||||
default: // 'pending' or 'in-progress'
|
||||
return `${defaultBase} bg-gray-50 border-gray-200`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isHeroVariant ? '' : "mb-12"}>
|
||||
<h3 className={`text-2xl font-bold text-center mb-8 ${isHeroVariant ? 'text-white' : 'text-brand-dark-blue'}`}>Review Progress</h3>
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 ${isHeroVariant ? 'gap-4' : 'gap-6'}`}>
|
||||
{AGENT_NAMES.map((agentName) => {
|
||||
const agentStatus = status[agentName] || 'pending';
|
||||
const iconColor = isHeroVariant ? 'text-brand-light-blue' : 'text-brand-accent';
|
||||
const Icon = agentIcons[agentName];
|
||||
|
||||
return (
|
||||
<div key={agentName} className={getTileClassName(agentStatus, isHeroVariant)}>
|
||||
<div className="flex-grow flex flex-col items-center justify-center">
|
||||
<div className="mb-3 h-8 w-8 flex items-center justify-center">
|
||||
<Icon className={`h-8 w-8 ${iconColor}`} />
|
||||
</div>
|
||||
<h4 className={`text-base font-bold ${isHeroVariant ? 'text-white' : 'text-brand-dark-blue'}`}>{agentName}</h4>
|
||||
</div>
|
||||
<div className="font-semibold text-xs mt-2 h-6 flex items-center justify-center">
|
||||
{agentStatus === 'in-progress' ? (
|
||||
<div className={`flex items-center ${isHeroVariant ? 'text-white/80' : 'text-gray-500'}`}>
|
||||
<SpinnerIcon className="h-4 w-4 mr-2 custom-spinner" />
|
||||
<span>Analyzing...</span>
|
||||
</div>
|
||||
) : (
|
||||
<StatusInfo
|
||||
status={agentStatus}
|
||||
isHeroVariant={isHeroVariant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
frontend/components/ToggleSwitch.tsx
Normal file
28
frontend/components/ToggleSwitch.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ enabled, onChange }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-2 ${
|
||||
enabled ? 'bg-brand-accent' : 'bg-gray-300'
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => onChange(!enabled)}
|
||||
>
|
||||
<span className="sr-only">Use setting</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
332
frontend/components/WIPReviewer.tsx
Normal file
332
frontend/components/WIPReviewer.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { DropdownOptions } from '../App';
|
||||
import { analyzeWIPProof, getWIPChatResponse } from '../services/geminiService';
|
||||
import type { AgentName } from '../types';
|
||||
import { UserIcon } from './icons/UserIcon';
|
||||
import { LeadAgentIcon } from './icons/LeadAgentIcon';
|
||||
import { PaperClipIcon } from './icons/PaperClipIcon';
|
||||
import { SendIcon } from './icons/SendIcon';
|
||||
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { DocumentIcon } from './icons/DocumentIcon';
|
||||
|
||||
|
||||
// --- TYPE DEFINITIONS ---
|
||||
interface TextContent { type: 'text'; text: string; }
|
||||
interface FilePreviewContent { type: 'file_preview'; fileName: string; previewUrl: string; mimeType: string; }
|
||||
interface ConfirmationContent { type: 'confirmation'; file: File; }
|
||||
interface LoadingContent { type: 'loading'; }
|
||||
interface ErrorContent { type: 'error'; text: string; }
|
||||
|
||||
type MessageContent = TextContent | FilePreviewContent | ConfirmationContent | LoadingContent | ErrorContent;
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
sender: 'user' | 'agent';
|
||||
content: MessageContent;
|
||||
}
|
||||
|
||||
const fileToDataUrl = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
// --- SUB-COMPONENTS ---
|
||||
|
||||
const ConfirmationComponent: React.FC<{
|
||||
dropdownOptions: DropdownOptions;
|
||||
onConfirm: (details: { channel: string; subChannel: string; proofType: string }) => void;
|
||||
}> = ({ dropdownOptions, onConfirm }) => {
|
||||
const [channel, setChannel] = useState('');
|
||||
const [subChannel, setSubChannel] = useState('');
|
||||
const [proofType, setProofType] = useState('');
|
||||
|
||||
const availableChannels = Object.keys(dropdownOptions.channels);
|
||||
const availableSubChannels = channel ? Object.keys(dropdownOptions.channels[channel] || {}) : [];
|
||||
const availableProofTypes = (channel && subChannel) ? (dropdownOptions.channels[channel][subChannel] || []) : [];
|
||||
const showProofType = availableProofTypes.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setSubChannel('');
|
||||
setProofType('');
|
||||
}, [channel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showProofType) setProofType('');
|
||||
else setProofType('');
|
||||
}, [subChannel, showProofType]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (channel && subChannel && (!showProofType || proofType)) {
|
||||
onConfirm({ channel, subChannel, proofType });
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitDisabled = !channel || !subChannel || (showProofType && !proofType);
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200 shadow-sm space-y-3">
|
||||
<p className="font-semibold text-gray-800">Please confirm the details for this proof:</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={channel}
|
||||
onChange={e => setChannel(e.target.value)}
|
||||
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
|
||||
>
|
||||
<option value="" disabled>Select Channel</option>
|
||||
{availableChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
value={subChannel}
|
||||
onChange={e => setSubChannel(e.target.value)}
|
||||
disabled={!channel}
|
||||
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none disabled:bg-gray-100"
|
||||
>
|
||||
<option value="" disabled>Select Sub-Channel</option>
|
||||
{availableSubChannels.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
||||
</div>
|
||||
</div>
|
||||
{showProofType && (
|
||||
<div className="relative">
|
||||
<select
|
||||
value={proofType}
|
||||
onChange={e => setProofType(e.target.value)}
|
||||
className="w-full bg-white border border-gray-300 rounded-md py-2 px-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-accent appearance-none"
|
||||
>
|
||||
<option value="" disabled>Select Proof Type</option>
|
||||
{availableProofTypes.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
<ChevronDownIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none"/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
className="w-full bg-brand-accent text-white font-bold py-2 px-4 rounded-md hover:bg-brand-dark-blue transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Confirm & Analyze
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const MessageBubble: React.FC<{ sender: 'user' | 'agent'; children: React.ReactNode }> = ({ sender, children }) => {
|
||||
const isUser = sender === 'user';
|
||||
const bubbleClasses = isUser
|
||||
? 'bg-brand-accent text-white'
|
||||
: 'bg-white text-gray-800 border border-gray-200';
|
||||
const alignmentClasses = isUser ? 'items-end' : 'items-start';
|
||||
const avatar = isUser
|
||||
? <div className="w-8 h-8 rounded-full bg-brand-dark-blue flex items-center justify-center ring-2 ring-white"><UserIcon className="h-5 w-5 text-white"/></div>
|
||||
: <div className="w-8 h-8 rounded-full bg-brand-light-blue flex items-center justify-center ring-2 ring-white"><LeadAgentIcon className="h-5 w-5 text-brand-dark-blue"/></div>;
|
||||
|
||||
const isConfirmation = React.isValidElement(children) && children.type === ConfirmationComponent;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${alignmentClasses} w-full max-w-lg mx-auto`}>
|
||||
<div className={`flex gap-3 items-end ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
|
||||
<div className="flex-shrink-0">{avatar}</div>
|
||||
<div className={
|
||||
isConfirmation
|
||||
? "w-full max-w-md"
|
||||
: `p-3 rounded-lg max-w-xs sm:max-w-md ${bubbleClasses}`
|
||||
}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- MAIN COMPONENT ---
|
||||
|
||||
export const WIPReviewer: React.FC<{ dropdownOptions: DropdownOptions }> = ({ dropdownOptions }) => {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: `msg_${Date.now()}`,
|
||||
sender: 'agent',
|
||||
content: { type: 'text', text: "Hello! I'm the Lead Agent. Upload a work-in-progress file for review, or ask me a question about marketing best practices." },
|
||||
}
|
||||
]);
|
||||
const [userInput, setUserInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const addMessage = (sender: 'user' | 'agent', content: MessageContent) => {
|
||||
setMessages(prev => [...prev, { id: `msg_${Date.now()}`, sender, content }]);
|
||||
};
|
||||
|
||||
const updateLastMessage = (newContent: MessageContent) => {
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1].content = newContent;
|
||||
return newMessages;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const previewUrl = await fileToDataUrl(file);
|
||||
addMessage('user', { type: 'file_preview', fileName: file.name, previewUrl, mimeType: file.type });
|
||||
addMessage('agent', { type: 'confirmation', file });
|
||||
} catch (error) {
|
||||
console.error("Error creating file preview:", error);
|
||||
addMessage('agent', { type: 'error', text: "Sorry, I couldn't load a preview for that file. Please try another file." });
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
if (event.target) event.target.value = '';
|
||||
};
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedInput = userInput.trim();
|
||||
if (!trimmedInput || isLoading) return;
|
||||
|
||||
addMessage('user', { type: 'text', text: trimmedInput });
|
||||
setUserInput('');
|
||||
setIsLoading(true);
|
||||
addMessage('agent', { type: 'loading' });
|
||||
|
||||
try {
|
||||
const responseText = await getWIPChatResponse(trimmedInput);
|
||||
updateLastMessage({ type: 'text', text: responseText });
|
||||
} catch (err) {
|
||||
console.error("Chat response failed:", err);
|
||||
updateLastMessage({ type: 'error', text: "Sorry, I couldn't get a response. Please try again." });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAnalysis = async (
|
||||
messageId: string,
|
||||
details: { channel: string; subChannel: string; proofType: string },
|
||||
file: File
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
// Replace confirmation message with loading indicator
|
||||
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'loading' } } : msg));
|
||||
|
||||
try {
|
||||
const summary = await analyzeWIPProof(file, () => {});
|
||||
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'text', text: summary } } : msg));
|
||||
} catch (err) {
|
||||
console.error("Analysis failed:", err);
|
||||
setMessages(prev => prev.map(msg => msg.id === messageId ? { ...msg, content: { type: 'error', text: "Sorry, an error occurred during the analysis. Please try again." } } : msg));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-brand-gray">
|
||||
<header className="p-4 border-b border-gray-200 bg-white/80 backdrop-blur-sm flex-shrink-0">
|
||||
<h1 className="text-xl font-bold text-brand-dark-blue text-center">WIP Reviewer</h1>
|
||||
</header>
|
||||
|
||||
{/* Message Display Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} sender={msg.sender}>
|
||||
{(() => {
|
||||
switch (msg.content.type) {
|
||||
case 'text':
|
||||
return <p className="whitespace-pre-wrap">{msg.content.text}</p>;
|
||||
case 'file_preview':
|
||||
const { mimeType, previewUrl, fileName } = msg.content;
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return <img src={previewUrl} alt={fileName} className="max-w-full h-auto rounded-md object-contain" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-2 bg-black/20 rounded-md">
|
||||
<DocumentIcon className="h-8 w-8 text-white"/>
|
||||
<span className="font-semibold truncate">{fileName}</span>
|
||||
</div>
|
||||
);
|
||||
case 'confirmation':
|
||||
return <ConfirmationComponent
|
||||
dropdownOptions={dropdownOptions}
|
||||
onConfirm={(details) => handleConfirmAnalysis(msg.id, details, msg.content.file)}
|
||||
/>;
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<SpinnerIcon className="h-5 w-5 custom-spinner"/>
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
);
|
||||
case 'error':
|
||||
return <p className="text-red-600 font-medium">{msg.content.text}</p>
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
</MessageBubble>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Message Input Area */}
|
||||
<div className="flex-shrink-0 p-4 bg-white/80 backdrop-blur-sm border-t border-gray-200">
|
||||
<div className="max-w-lg mx-auto">
|
||||
<form onSubmit={handleSendMessage} className="relative flex items-center">
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileSelect} className="hidden" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:text-brand-accent rounded-full transition-colors disabled:opacity-50"
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<PaperClipIcon className="h-6 w-6"/>
|
||||
</button>
|
||||
<textarea
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage(e);
|
||||
}
|
||||
}}
|
||||
className="w-full p-2.5 mx-2 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-brand-accent focus:border-brand-accent transition"
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-2.5 rounded-full text-white bg-brand-accent hover:bg-brand-dark-blue disabled:bg-gray-400 transition-colors"
|
||||
disabled={!userInput.trim() || isLoading}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<SendIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
frontend/components/icons/AnalyticsIcon.tsx
Normal file
8
frontend/components/icons/AnalyticsIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const AnalyticsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/ArrowDownIcon.tsx
Normal file
7
frontend/components/icons/ArrowDownIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ArrowDownIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/ArrowLeftIcon.tsx
Normal file
7
frontend/components/icons/ArrowLeftIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ArrowLeftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/AuditIcon.tsx
Normal file
7
frontend/components/icons/AuditIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const AuditIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
|
||||
</svg>
|
||||
);
|
||||
13
frontend/components/icons/BarclaysLogo.tsx
Normal file
13
frontend/components/icons/BarclaysLogo.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
export const BarclaysLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-label="Barclays Logo"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 1.5c-4.3 0-7.8 3.5-7.8 7.8 0 1.9.7 3.7 1.9 5.1-1-1.3-1.6-2.9-1.6-4.6 0-3.5 2.8-6.3 6.3-6.3s6.3 2.8 6.3 6.3c0 1.7-.6 3.3-1.6 4.6 1.2-1.4 1.9-3.2 1.9-5.1 0-4.3-3.5-7.8-7.8-7.8zm.5 15.6c-.3.3-.7.4-1.1.4s-.8-.1-1.1-.4c-1.9-1.9-3.9-2.9-6.3-2.8-.5 0-1 .4-1 .9s.4 1 .9 1c2.8.1 5.2 1.3 7.5 3.5.3.3.7.4 1.1.4s.8-.1 1.1-.4c2.3-2.2 4.7-3.4 7.5-3.5.5 0 .9-.5.9-1s-.4-1-.9-.9c-2.4-.1-4.4 1-6.3 2.8z" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/BestPracticeIcon.tsx
Normal file
8
frontend/components/icons/BestPracticeIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const BestPracticeIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6.633 10.5c.806 0 1.533-.422 2.031-1.096a.5.5 0 01.838-.195l1.122.957.56-1.107a3.75 3.75 0 015.44-2.323 3.75 3.75 0 011.604 4.078l-2.92 11.23a.75.75 0 01-1.424-.39l-.01-1.353a4.5 4.5 0 00-4.49-4.33H8.25a.75.75 0 01-.75-.75V11.25a.75.75 0 01.75-.75h.383zM10.875 6.75a4.5 4.5 0 00-4.5 4.5v.625c0 .414.336.75.75.75h3.375c.414 0 .75-.336.75-.75V10.5a4.5 4.5 0 00-4.5-4.5h-.375z" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/BrandIcon.tsx
Normal file
8
frontend/components/icons/BrandIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const BrandIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.385m5.043.025a2.25 2.25 0 012.245-2.4 4.5 4.5 0 00-2.245-8.4-4.5 4.5 0 00-8.4 2.245 2.25 2.25 0 01-2.4 2.4m-2.245 8.4a4.5 4.5 0 008.4 2.245 2.25 2.25 0 012.4-2.4m8.4-2.245a4.5 4.5 0 00-2.245-8.4 2.25 2.25 0 01-2.4-2.4" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/BriefcaseIcon.tsx
Normal file
7
frontend/components/icons/BriefcaseIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const BriefcaseIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.075c0 1.313-.964 2.47-2.25 2.75h-12c-1.286-.28-2.25-1.437-2.25-2.75V14.15M17.25 6.75v4.5m-3.75-4.5v4.5m-3.75-4.5v4.5m-3.75 0V6.75m11.25 0h-9A2.25 2.25 0 006 9v3c0 1.242 1.008 2.25 2.25 2.25h9A2.25 2.25 0 0019.5 12V9a2.25 2.25 0 00-2.25-2.25h-2.25" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/BugIcon.tsx
Normal file
8
frontend/components/icons/BugIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const BugIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 018.638 5.214m-6.724 0a3.375 3.375 0 01-1.464 3.362M15.362 5.214A3.375 3.375 0 0112 3.75a3.375 3.375 0 01-3.362 1.464m-3.376 0a3.375 3.375 0 013.376 0m6.724 0a3.375 3.375 0 011.464 3.362M12 21a3.375 3.375 0 01-3.362-1.464M3 12a3.375 3.375 0 011.464-3.362m14.144 0A3.375 3.375 0 0121 12a3.375 3.375 0 01-1.464 3.362M12 3.75a2.625 2.625 0 00-2.625 2.625v.375m0 0a2.625 2.625 0 015.25 0v-.375a2.625 2.625 0 00-2.625-2.625M12 21v-1.5" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/CampaignsIcon.tsx
Normal file
7
frontend/components/icons/CampaignsIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const CampaignsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.075c0 1.313-.964 2.47-2.25 2.75h-12c-1.286-.28-2.25-1.437-2.25-2.75V14.15M17.25 6.75v4.5m-3.75-4.5v4.5m-3.75-4.5v4.5m-3.75 0V6.75m11.25 0h-9A2.25 2.25 0 006 9v3c0 1.242 1.008 2.25 2.25 2.25h9A2.25 2.25 0 0019.5 12V9a2.25 2.25 0 00-2.25-2.25h-2.25" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/ChannelIcon.tsx
Normal file
8
frontend/components/icons/ChannelIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const ChannelIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.898 20.572L16.25 21.75l-.648-1.178a2.625 2.625 0 00-1.94-1.94l-1.178-.648 1.178-.648a2.625 2.625 0 001.94-1.94l.648-1.178.648 1.178a2.625 2.625 0 001.94 1.94l1.178.648-1.178.648a2.625 2.625 0 00-1.94 1.94z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/ChatBubbleIcon.tsx
Normal file
7
frontend/components/icons/ChatBubbleIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ChatBubbleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193l-3.72 3.72a1.5 1.5 0 01-2.12 0l-3.72-3.72C6.347 20.77 5.5 19.796 5.5 18.66v-4.286c0-.97.616-1.813 1.5-2.097m6.5-3.511a.75.75 0 00-1.5 0v1.5a.75.75 0 001.5 0v-1.5z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/ChevronDownIcon.tsx
Normal file
7
frontend/components/icons/ChevronDownIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ChevronDownIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/ClipboardIcon.tsx
Normal file
7
frontend/components/icons/ClipboardIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ClipboardIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a2.25 2.25 0 01-2.25 2.25h-1.5a2.25 2.25 0 01-2.25-2.25v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/ClockIcon.tsx
Normal file
8
frontend/components/icons/ClockIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const ClockIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/CopyGenAIIcon.tsx
Normal file
7
frontend/components/icons/CopyGenAIIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const CopyGenAIIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/CopyIcon.tsx
Normal file
8
frontend/components/icons/CopyIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const CopyIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/DashboardIcon.tsx
Normal file
8
frontend/components/icons/DashboardIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const DashboardIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/DocumentIcon.tsx
Normal file
7
frontend/components/icons/DocumentIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const DocumentIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/DownloadIcon.tsx
Normal file
7
frontend/components/icons/DownloadIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const DownloadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
);
|
||||
15
frontend/components/icons/EagleIcon.tsx
Normal file
15
frontend/components/icons/EagleIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const EagleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12.63,2.18C12.43,2.07,12.21,2,12,2s-0.43,0.07-0.63,0.18L4.8,6.21C4.4,6.42,4.4,7.08,4.8,7.29L11.37,11 c0.11,0.06,0.24,0.09,0.37,0.09c0.23,0,0.45-0.09,0.63-0.27l6.2-5.59c0.39-0.35,0.43-0.95,0.08-1.34 C18.3,3.44,17.7,3.4,17.3,3.75L12,8.42L6.7,4.21l5.3-2.65V14c0,0.55,0.45,1,1,1s1-0.45,1-1V1.58 c0-0.23-0.09-0.45-0.27-0.63L12.63,2.18z" />
|
||||
<path d="M19,13c-2.76,0-5,2.24-5,5s2.24,5,5,5s5-2.24,5-5S21.76,13,19,13z M19,21c-1.65,0-3-1.35-3-3s1.35-3,3-3 s3,1.35,3,3S20.65,21,19,21z" />
|
||||
<path d="M12,14c-3.31,0-6,2.69-6,6s2.69,6,6,6h1v-2h-1c-2.21,0-4-1.79-4-4s1.79-4,4-4s4,1.79,4,4v2.35 c0.61-0.21,1.27-0.35,2-0.35v-1c0-3.31-2.69-6-6-6H12z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/ExportIcon.tsx
Normal file
7
frontend/components/icons/ExportIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ExportIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/FlagIcon.tsx
Normal file
7
frontend/components/icons/FlagIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const FlagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v1.5M3 21v-6m0 0l2.77-.693a9 9 0 016.208.682l.108.054a9 9 0 006.086.71l3.114-.732a48.524 48.524 0 01-.005-10.499l-3.11.732a9 9 0 01-6.085-.711l-.108-.054a9 9 0 00-6.208-.682L3 4.5M3 15V4.5" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/HistoryIcon.tsx
Normal file
8
frontend/components/icons/HistoryIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
export const HistoryIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" transform="scale(0.8) translate(3, 3)" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/LeadAgentIcon.tsx
Normal file
8
frontend/components/icons/LeadAgentIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
export const LeadAgentIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 21v-1.5M15.75 3v1.5m0 16.5v-1.5m3.75-12H21m-18 0h1.5m15 3.75H21m-18 0h1.5m15 3.75H21m-18 0h1.5M15.75 21v-1.5m-7.5 0v-1.5m0-15V3" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 8.25a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/LegalIcon.tsx
Normal file
8
frontend/components/icons/LegalIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const LegalIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.52.176 1.07.332 1.634.493l-2.496 3.03m-2.496-3.03c-.564-.16-1.114-.317-1.634-.493L2.25 21M9.564 5.074A5.96 5.96 0 0112 4.5a5.96 5.96 0 012.436.574M9.564 5.074L5.073 9.564A5.96 5.96 0 014.5 12a5.96 5.96 0 01.574 2.436m4.49-6.862L12 2.25" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/LightbulbIcon.tsx
Normal file
8
frontend/components/icons/LightbulbIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const LightbulbIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.311V21m-3.75-2.311V21m-3.75-2.101a7.5 7.5 0 01-4.471-1.382m5.942 0a7.5 7.5 0 00-5.942 0M15 21a9 9 0 00-6 0m3-10.5V4.5A2.25 2.25 0 0112 2.25v0A2.25 2.25 0 0114.25 4.5v3.75m-2.25 0V6.75" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/LogoutIcon.tsx
Normal file
8
frontend/components/icons/LogoutIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const LogoutIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
</svg>
|
||||
);
|
||||
11
frontend/components/icons/MicrosoftLogo.tsx
Normal file
11
frontend/components/icons/MicrosoftLogo.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const MicrosoftLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" {...props}>
|
||||
<path fill="#f35325" d="M1 1h10v10H1z"/>
|
||||
<path fill="#81bc06" d="M12 1h10v10H12z"/>
|
||||
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
|
||||
<path fill="#ffba08" d="M12 12h10v10H12z"/>
|
||||
</svg>
|
||||
);
|
||||
16
frontend/components/icons/OliverLogo.tsx
Normal file
16
frontend/components/icons/OliverLogo.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
export const OliverLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<text
|
||||
x="10"
|
||||
y="28"
|
||||
fontFamily="Arial, sans-serif"
|
||||
fontSize="28"
|
||||
fontWeight="bold"
|
||||
fill="#333"
|
||||
>
|
||||
OLIVER
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/PDFIcon.tsx
Normal file
7
frontend/components/icons/PDFIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const PDFIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/PaperClipIcon.tsx
Normal file
7
frontend/components/icons/PaperClipIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const PaperClipIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.375 12.739l-7.693 7.693a4.5 4.5 0 01-6.364-6.364l10.94-10.94A3 3 0 1119.5 7.372L8.552 18.32m.009-.01l-.01.01m5.699-9.941l-7.81 7.81a1.5 1.5 0 002.122 2.122l7.81-7.81" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/PlusIcon.tsx
Normal file
7
frontend/components/icons/PlusIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const PlusIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/ProjectsIcon.tsx
Normal file
8
frontend/components/icons/ProjectsIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const ProjectsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.075c0 1.313-.964 2.47-2.25 2.75h-12c-1.286-.28-2.25-1.437-2.25-2.75V14.15M17.25 6.75v4.5m-3.75-4.5v4.5m-3.75-4.5v4.5m-3.75 0V6.75m11.25 0h-9A2.25 2.25 0 006 9v3c0 1.242 1.008 2.25 2.25 2.25h9A2.25 2.25 0 0019.5 12V9a2.25 2.25 0 00-2.25-2.25h-2.25" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/QuestionMarkIcon.tsx
Normal file
8
frontend/components/icons/QuestionMarkIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const QuestionMarkIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/SendIcon.tsx
Normal file
7
frontend/components/icons/SendIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const SendIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
);
|
||||
9
frontend/components/icons/SettingsIcon.tsx
Normal file
9
frontend/components/icons/SettingsIcon.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const SettingsIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.438.995s.145.755.438.995l1.003.827c.48.398.665.998.406 1.431l-1.296 2.247a1.125 1.125 0 01-1.37.49l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.333.183-.582.495-.645.87l-.213 1.28c-.09.543-.56.94-1.11.94h-2.593c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.063-.374-.313-.686-.645-.87a6.52 6.52 0 01-.22-.127c-.324-.196-.72-.257-1.075-.124l-1.217.456a1.125 1.125 0 01-1.37-.49l-1.296-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.437-.995s-.145-.755-.437-.995l-1.004-.827a1.125 1.125 0 01-.406-1.431l1.296-2.247a1.125 1.125 0 011.37-.49l1.217.456c.355.133.75.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.645-.87l.213-1.28z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
32
frontend/components/icons/SpinnerIcon.tsx
Normal file
32
frontend/components/icons/SpinnerIcon.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
|
||||
export const SpinnerIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
{/* Background track */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="10"
|
||||
className="opacity-25"
|
||||
/>
|
||||
{/* Spinning arc */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="10"
|
||||
strokeDasharray="70 283"
|
||||
strokeLinecap="round"
|
||||
className="opacity-75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
26
frontend/components/icons/StatusIcons.tsx
Normal file
26
frontend/components/icons/StatusIcons.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const CheckCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ExclamationTriangleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InformationCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.852l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowPathIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0011.664 0l3.181-3.183m-3.181-3.182l-3.182 3.182a8.25 8.25 0 01-11.664 0l-3.182-3.182m3.182-3.182L6.36 12.64m11.664 0l-3.182-3.182" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/TagIcon.tsx
Normal file
8
frontend/components/icons/TagIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
|
||||
export const TagIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/TrashIcon.tsx
Normal file
7
frontend/components/icons/TrashIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const TrashIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.134-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.067-2.09.92-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/TrendingUpIcon.tsx
Normal file
8
frontend/components/icons/TrendingUpIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const TrendingUpIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-3.75-2.25M21 3.75v4.5M21 3.75H16.5" />
|
||||
</svg>
|
||||
);
|
||||
8
frontend/components/icons/UploadIcon.tsx
Normal file
8
frontend/components/icons/UploadIcon.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0l-3.75 3.75M12 9.75l3.75 3.75M3 17.25V19.5a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 19.5v-2.25" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/UserIcon.tsx
Normal file
7
frontend/components/icons/UserIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const UserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
);
|
||||
13
frontend/components/icons/WorkfrontLogo.tsx
Normal file
13
frontend/components/icons/WorkfrontLogo.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
export const WorkfrontLogo: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 55.4"
|
||||
fill="currentColor"
|
||||
aria-label="Workfront Logo"
|
||||
{...props}
|
||||
>
|
||||
<path d="M251.5 48.2h-5.2l-2.4-8.8h-7.6l-2.4 8.8h-5.2l8.8-24.5h5.2l8.8 24.5zm-11.4-12.9h5.1l-2.5-9.1-2.6 9.1zM224 48.2h-4.6V33.9h-5.9v14.3h-4.6V23.7h15.1v24.5zM203.4 48.2h-13V23.7h4.6v20.4h8.4v4.1zM182.2 48.2h-4.6V27.8l-5.6-4.1h6.1l4.1 3v21.5zM157.9 48.2h-4.6V30l-7.3 18.2h-4.3l-7.3-18.2v18.2h-4.6V23.7h6.4l6.2 15.6 6.2-15.6h6.4v24.5zM108.9 48.2h-5.2l-2.4-8.8h-7.6l-2.4 8.8H86l8.8-24.5h5.2l8.8 24.5zm-11.4-12.9h5.1l-2.5-9.1-2.6 9.1zM81.2 48.2H68.1V23.7h13.1v4.1H72.7v5.3h7.9v4.1H72.7v6.9h8.5v4.1zM58.7 48.2h-4.6l-8.3-11.1v11.1H41V23.7h4.8l8.2 11V23.7h4.7v24.5zM30.8 48.2H17.7V23.7h13.1v4.1H22.3v5.3h7.9v4.1H22.3v6.9h8.5v4.1zM0 55.4h13.3V44.3H2.8V33.6h9.4V22.5H2.8V11.1h10.5V0H0v55.4z" />
|
||||
</svg>
|
||||
);
|
||||
7
frontend/components/icons/XIcon.tsx
Normal file
7
frontend/components/icons/XIcon.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
export const XIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
4
frontend/constants.ts
Normal file
4
frontend/constants.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
import type { AgentName } from './types';
|
||||
|
||||
export const AGENT_NAMES: AgentName[] = ['Legal Agent', 'Brand Agent', 'Tone Agent', 'Channel Agent'];
|
||||
66
frontend/index.html
Normal file
66
frontend/index.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI Marketing Proof Reviewer</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand-dark-blue': '#001f5a',
|
||||
'brand-light-blue': '#00a3e0',
|
||||
'brand-accent': '#0070c0',
|
||||
'brand-gray': '#f4f6f8',
|
||||
'sidebar-bg': '#1f2937',
|
||||
},
|
||||
animation: {
|
||||
'slow-ping': 'ping 2s cubic-bezier(0, 0, 0.2, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
ping: {
|
||||
'75%, 100%': {
|
||||
transform: 'scale(2)',
|
||||
opacity: '0'
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@keyframes spin-animation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.custom-spinner {
|
||||
animation: spin-animation 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://aistudiocdn.com/react@^19.1.1",
|
||||
"react/": "https://aistudiocdn.com/react@^19.1.1/",
|
||||
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.20.0",
|
||||
"react-dom/": "https://aistudiocdn.com/react-dom@^19.1.1/",
|
||||
"jspdf": "https://aistudiocdn.com/jspdf@^2.5.1",
|
||||
"html2canvas": "https://aistudiocdn.com/html2canvas@^1.4.1"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
frontend/index.tsx
Normal file
16
frontend/index.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
5
frontend/metadata.json
Normal file
5
frontend/metadata.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "Barclays ModComms v5 Prototype",
|
||||
"description": "An application that allows users to upload marketing proofs (like web banners or email templates) for an AI-driven review. A main AI agent coordinates with specialized agents for legal, brand, and copywriting compliance, providing summarized feedback.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
2779
frontend/package-lock.json
generated
Normal file
2779
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue