feat(reviewiq): Add AI synthesis support to dashboard components
Frontend: - Add Synthesis type with action plan, insights, annotations - ExecutiveSummary: Accept synthesis prop for AI narrative - SentimentPie: Accept insight prop for contextual explanation - IntensityHeatmap: Accept insight + highlightDomain props - TimelineChart: Accept insight + annotations props - All components gracefully degrade when synthesis is null Backend: - Add Stage 4: Synthesize for generating AI narratives - Gathers context from classified spans - Generates executive narrative, section insights, action plan - Produces timeline annotations and marketing angles - Stores synthesis in pipeline.executions table Components show AI insights with purple gradient styling when available, fall back to existing behavior when synthesis is not yet generated. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,477 @@
|
|||||||
|
"""
|
||||||
|
Stage 4: Synthesize - Generate AI narratives and action plans.
|
||||||
|
|
||||||
|
This stage runs after classification and routing to produce:
|
||||||
|
- Executive narrative (business-specific story)
|
||||||
|
- Section insights (sentiment, category, timeline)
|
||||||
|
- Action plan with prioritized recommendations
|
||||||
|
- Timeline annotations for key events
|
||||||
|
- Marketing angles from strengths
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from reviewiq_pipeline.services.llm_client import LLMClientBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionItem:
|
||||||
|
"""A specific action recommendation."""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
why: str
|
||||||
|
what: str
|
||||||
|
who: str
|
||||||
|
impact: str
|
||||||
|
evidence: list[str]
|
||||||
|
estimated_rating_lift: float | None
|
||||||
|
complexity: str # 'quick' | 'medium' | 'complex'
|
||||||
|
priority: str # 'critical' | 'high' | 'medium' | 'low'
|
||||||
|
timeline: str
|
||||||
|
related_subcode: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimelineAnnotation:
|
||||||
|
"""An annotation for a key event on the timeline."""
|
||||||
|
date: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
type: str # 'positive' | 'negative' | 'neutral' | 'event'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Synthesis:
|
||||||
|
"""Complete synthesis output from Stage 4."""
|
||||||
|
executive_narrative: str
|
||||||
|
sentiment_insight: str
|
||||||
|
category_insight: str
|
||||||
|
timeline_insight: str
|
||||||
|
priority_domain: str | None
|
||||||
|
priority_issue: str | None
|
||||||
|
action_plan: list[ActionItem]
|
||||||
|
issue_actions: dict[str, str]
|
||||||
|
timeline_annotations: list[TimelineAnnotation]
|
||||||
|
marketing_angles: list[str]
|
||||||
|
competitor_context: str | None
|
||||||
|
generated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
SYNTHESIS_SYSTEM_PROMPT = """You are an expert business analyst specializing in customer experience and review analysis.
|
||||||
|
|
||||||
|
Your task is to analyze classified review data and generate actionable business insights.
|
||||||
|
|
||||||
|
You will receive:
|
||||||
|
1. Summary statistics (total reviews, rating, sentiment distribution)
|
||||||
|
2. Top issues by category with example quotes
|
||||||
|
3. Top strengths with example quotes
|
||||||
|
4. Domain breakdown (what customers talk about most)
|
||||||
|
|
||||||
|
Generate a JSON response with these fields:
|
||||||
|
|
||||||
|
{
|
||||||
|
"executive_narrative": "2-3 paragraph story explaining the business situation, key problems, and path forward. Be specific with numbers and examples.",
|
||||||
|
|
||||||
|
"sentiment_insight": "1-2 sentences explaining WHY sentiment is distributed this way. Connect to specific issues.",
|
||||||
|
|
||||||
|
"category_insight": "1-2 sentences about the pattern in categories. Which domain needs most attention and why?",
|
||||||
|
|
||||||
|
"timeline_insight": "1-2 sentences about trends if data shows changes over time.",
|
||||||
|
|
||||||
|
"priority_domain": "Single letter code (P/V/J/O/A/E/R) for the domain needing most attention, or null",
|
||||||
|
|
||||||
|
"priority_issue": "The subcode (e.g., 'V1.03') that should be fixed first, or null",
|
||||||
|
|
||||||
|
"action_plan": [
|
||||||
|
{
|
||||||
|
"id": "action_1",
|
||||||
|
"title": "Clear action title",
|
||||||
|
"why": "Root cause from the reviews",
|
||||||
|
"what": "Specific steps to take",
|
||||||
|
"who": "Department or role responsible",
|
||||||
|
"impact": "Expected outcome",
|
||||||
|
"evidence": ["Quote 1", "Quote 2"],
|
||||||
|
"estimated_rating_lift": 0.3,
|
||||||
|
"complexity": "quick|medium|complex",
|
||||||
|
"priority": "critical|high|medium|low",
|
||||||
|
"timeline": "This week|This month|This quarter",
|
||||||
|
"related_subcode": "V1.03"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"timeline_annotations": [
|
||||||
|
{
|
||||||
|
"date": "2024-01-15",
|
||||||
|
"label": "Short label",
|
||||||
|
"description": "What happened",
|
||||||
|
"type": "positive|negative|neutral|event"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"marketing_angles": [
|
||||||
|
"Way to promote strength 1",
|
||||||
|
"Way to promote strength 2"
|
||||||
|
],
|
||||||
|
|
||||||
|
"competitor_context": "How this compares to industry/competitors, or null if unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
Be specific, actionable, and business-focused. Use actual numbers and quotes from the data.
|
||||||
|
Prioritize actions by impact and feasibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SynthesisStage:
|
||||||
|
"""
|
||||||
|
Stage 4: Generate AI synthesis from classified review data.
|
||||||
|
|
||||||
|
This stage:
|
||||||
|
1. Aggregates classification results
|
||||||
|
2. Identifies patterns and priorities
|
||||||
|
3. Generates narrative insights via LLM
|
||||||
|
4. Produces actionable recommendations
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pool: asyncpg.Pool, llm_client: LLMClientBase):
|
||||||
|
self.pool = pool
|
||||||
|
self.llm_client = llm_client
|
||||||
|
|
||||||
|
async def run(self, job_id: str, execution_id: str) -> Synthesis:
|
||||||
|
"""
|
||||||
|
Generate synthesis for a completed pipeline execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
job_id: The scraping job ID
|
||||||
|
execution_id: The pipeline execution ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Synthesis object with all generated insights
|
||||||
|
"""
|
||||||
|
logger.info(f"Stage 4: Generating synthesis for job {job_id}")
|
||||||
|
|
||||||
|
# Gather all the data we need
|
||||||
|
context = await self._gather_context(job_id)
|
||||||
|
|
||||||
|
# Generate synthesis via LLM
|
||||||
|
synthesis = await self._generate_synthesis(context)
|
||||||
|
|
||||||
|
# Store synthesis in database
|
||||||
|
await self._store_synthesis(execution_id, synthesis)
|
||||||
|
|
||||||
|
logger.info(f"Stage 4: Synthesis complete - {len(synthesis.action_plan)} actions generated")
|
||||||
|
return synthesis
|
||||||
|
|
||||||
|
async def _gather_context(self, job_id: str) -> dict[str, Any]:
|
||||||
|
"""Gather all context needed for synthesis."""
|
||||||
|
|
||||||
|
# Get overview stats
|
||||||
|
overview = await self.pool.fetchrow("""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT r.review_id) as total_reviews,
|
||||||
|
AVG(r.rating) as avg_rating,
|
||||||
|
COUNT(s.span_id) as total_spans
|
||||||
|
FROM reviews r
|
||||||
|
LEFT JOIN pipeline.spans s ON s.source_review_id = r.review_id
|
||||||
|
WHERE r.job_id = $1
|
||||||
|
""", job_id)
|
||||||
|
|
||||||
|
# Get sentiment distribution
|
||||||
|
sentiment = await self.pool.fetch("""
|
||||||
|
SELECT
|
||||||
|
valence,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COUNT(DISTINCT source_review_id) as review_count
|
||||||
|
FROM pipeline.spans
|
||||||
|
WHERE job_id = $1 AND valence IS NOT NULL
|
||||||
|
GROUP BY valence
|
||||||
|
ORDER BY count DESC
|
||||||
|
""", job_id)
|
||||||
|
|
||||||
|
# Get top issues (weaknesses)
|
||||||
|
top_issues = await self.pool.fetch("""
|
||||||
|
SELECT
|
||||||
|
s.urt_primary as subcode,
|
||||||
|
sc.name as subcode_name,
|
||||||
|
sc.definition,
|
||||||
|
d.code as domain,
|
||||||
|
d.name as domain_name,
|
||||||
|
COUNT(*) as span_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.valence = 'V-') as negative_count,
|
||||||
|
ARRAY_AGG(s.span_text ORDER BY s.intensity DESC) FILTER (WHERE s.valence = 'V-') as example_quotes
|
||||||
|
FROM pipeline.spans s
|
||||||
|
JOIN pipeline.urt_subcodes sc ON sc.code = s.urt_primary
|
||||||
|
JOIN pipeline.urt_domains d ON d.code = SUBSTRING(s.urt_primary, 1, 1)
|
||||||
|
WHERE s.job_id = $1 AND s.valence = 'V-'
|
||||||
|
GROUP BY s.urt_primary, sc.name, sc.definition, d.code, d.name
|
||||||
|
ORDER BY negative_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
""", job_id)
|
||||||
|
|
||||||
|
# Get top strengths
|
||||||
|
top_strengths = await self.pool.fetch("""
|
||||||
|
SELECT
|
||||||
|
s.urt_primary as subcode,
|
||||||
|
sc.name as subcode_name,
|
||||||
|
sc.definition,
|
||||||
|
d.code as domain,
|
||||||
|
d.name as domain_name,
|
||||||
|
COUNT(*) as span_count,
|
||||||
|
COUNT(*) FILTER (WHERE s.valence = 'V+') as positive_count,
|
||||||
|
ARRAY_AGG(s.span_text ORDER BY s.intensity DESC) FILTER (WHERE s.valence = 'V+') as example_quotes
|
||||||
|
FROM pipeline.spans s
|
||||||
|
JOIN pipeline.urt_subcodes sc ON sc.code = s.urt_primary
|
||||||
|
JOIN pipeline.urt_domains d ON d.code = SUBSTRING(s.urt_primary, 1, 1)
|
||||||
|
WHERE s.job_id = $1 AND s.valence = 'V+'
|
||||||
|
GROUP BY s.urt_primary, sc.name, sc.definition, d.code, d.name
|
||||||
|
ORDER BY positive_count DESC
|
||||||
|
LIMIT 5
|
||||||
|
""", job_id)
|
||||||
|
|
||||||
|
# Get domain distribution
|
||||||
|
domains = await self.pool.fetch("""
|
||||||
|
SELECT
|
||||||
|
SUBSTRING(urt_primary, 1, 1) as domain,
|
||||||
|
d.name as domain_name,
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COUNT(*) FILTER (WHERE valence = 'V+') as positive_count,
|
||||||
|
COUNT(*) FILTER (WHERE valence = 'V-') as negative_count
|
||||||
|
FROM pipeline.spans s
|
||||||
|
JOIN pipeline.urt_domains d ON d.code = SUBSTRING(s.urt_primary, 1, 1)
|
||||||
|
WHERE s.job_id = $1
|
||||||
|
GROUP BY SUBSTRING(urt_primary, 1, 1), d.name
|
||||||
|
ORDER BY total_count DESC
|
||||||
|
""", job_id)
|
||||||
|
|
||||||
|
# Get business name if available
|
||||||
|
business = await self.pool.fetchrow("""
|
||||||
|
SELECT DISTINCT business_name
|
||||||
|
FROM reviews
|
||||||
|
WHERE job_id = $1 AND business_name IS NOT NULL
|
||||||
|
LIMIT 1
|
||||||
|
""", job_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"business_name": business["business_name"] if business else "This business",
|
||||||
|
"overview": dict(overview) if overview else {},
|
||||||
|
"sentiment": [dict(r) for r in sentiment],
|
||||||
|
"top_issues": [dict(r) for r in top_issues],
|
||||||
|
"top_strengths": [dict(r) for r in top_strengths],
|
||||||
|
"domains": [dict(r) for r in domains],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _generate_synthesis(self, context: dict[str, Any]) -> Synthesis:
|
||||||
|
"""Generate synthesis using LLM."""
|
||||||
|
|
||||||
|
# Build the user prompt with context
|
||||||
|
user_prompt = f"""Analyze this review data for {context['business_name']}:
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
- Total Reviews: {context['overview'].get('total_reviews', 0)}
|
||||||
|
- Average Rating: {context['overview'].get('avg_rating', 'N/A')}
|
||||||
|
- Total Insights Extracted: {context['overview'].get('total_spans', 0)}
|
||||||
|
|
||||||
|
## Sentiment Distribution
|
||||||
|
{self._format_sentiment(context['sentiment'])}
|
||||||
|
|
||||||
|
## Top Issues (Problems)
|
||||||
|
{self._format_issues(context['top_issues'])}
|
||||||
|
|
||||||
|
## Top Strengths
|
||||||
|
{self._format_strengths(context['top_strengths'])}
|
||||||
|
|
||||||
|
## Domain Breakdown
|
||||||
|
{self._format_domains(context['domains'])}
|
||||||
|
|
||||||
|
Generate a complete synthesis with actionable insights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Call LLM
|
||||||
|
try:
|
||||||
|
response = await self.llm_client.generate(
|
||||||
|
system_prompt=SYNTHESIS_SYSTEM_PROMPT,
|
||||||
|
user_prompt=user_prompt,
|
||||||
|
temperature=0.7, # Allow some creativity
|
||||||
|
max_tokens=4000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse JSON response
|
||||||
|
result = json.loads(response)
|
||||||
|
|
||||||
|
# Convert to Synthesis object
|
||||||
|
return Synthesis(
|
||||||
|
executive_narrative=result.get("executive_narrative", ""),
|
||||||
|
sentiment_insight=result.get("sentiment_insight", ""),
|
||||||
|
category_insight=result.get("category_insight", ""),
|
||||||
|
timeline_insight=result.get("timeline_insight", ""),
|
||||||
|
priority_domain=result.get("priority_domain"),
|
||||||
|
priority_issue=result.get("priority_issue"),
|
||||||
|
action_plan=[
|
||||||
|
ActionItem(
|
||||||
|
id=a.get("id", f"action_{i}"),
|
||||||
|
title=a.get("title", ""),
|
||||||
|
why=a.get("why", ""),
|
||||||
|
what=a.get("what", ""),
|
||||||
|
who=a.get("who", ""),
|
||||||
|
impact=a.get("impact", ""),
|
||||||
|
evidence=a.get("evidence", []),
|
||||||
|
estimated_rating_lift=a.get("estimated_rating_lift"),
|
||||||
|
complexity=a.get("complexity", "medium"),
|
||||||
|
priority=a.get("priority", "medium"),
|
||||||
|
timeline=a.get("timeline", "This month"),
|
||||||
|
related_subcode=a.get("related_subcode", ""),
|
||||||
|
)
|
||||||
|
for i, a in enumerate(result.get("action_plan", []))
|
||||||
|
],
|
||||||
|
issue_actions={}, # Can be populated from action_plan
|
||||||
|
timeline_annotations=[
|
||||||
|
TimelineAnnotation(
|
||||||
|
date=t.get("date", ""),
|
||||||
|
label=t.get("label", ""),
|
||||||
|
description=t.get("description", ""),
|
||||||
|
type=t.get("type", "neutral"),
|
||||||
|
)
|
||||||
|
for t in result.get("timeline_annotations", [])
|
||||||
|
],
|
||||||
|
marketing_angles=result.get("marketing_angles", []),
|
||||||
|
competitor_context=result.get("competitor_context"),
|
||||||
|
generated_at=datetime.utcnow().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse LLM response: {e}")
|
||||||
|
return self._create_fallback_synthesis()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Synthesis generation failed: {e}")
|
||||||
|
return self._create_fallback_synthesis()
|
||||||
|
|
||||||
|
def _format_sentiment(self, sentiment: list[dict]) -> str:
|
||||||
|
"""Format sentiment data for prompt."""
|
||||||
|
lines = []
|
||||||
|
for s in sentiment:
|
||||||
|
valence = s.get("valence", "Unknown")
|
||||||
|
count = s.get("count", 0)
|
||||||
|
reviews = s.get("review_count", 0)
|
||||||
|
label = {"V+": "Positive", "V-": "Negative", "V0": "Neutral", "V±": "Mixed"}.get(valence, valence)
|
||||||
|
lines.append(f"- {label}: {count} mentions ({reviews} reviews)")
|
||||||
|
return "\n".join(lines) or "No sentiment data"
|
||||||
|
|
||||||
|
def _format_issues(self, issues: list[dict]) -> str:
|
||||||
|
"""Format issues for prompt."""
|
||||||
|
lines = []
|
||||||
|
for i, issue in enumerate(issues[:5], 1):
|
||||||
|
subcode = issue.get("subcode", "")
|
||||||
|
name = issue.get("subcode_name", "")
|
||||||
|
domain = issue.get("domain_name", "")
|
||||||
|
count = issue.get("negative_count", 0)
|
||||||
|
quotes = issue.get("example_quotes", [])[:2]
|
||||||
|
|
||||||
|
lines.append(f"{i}. [{subcode}] {name} ({domain})")
|
||||||
|
lines.append(f" - {count} negative mentions")
|
||||||
|
for q in quotes:
|
||||||
|
if q:
|
||||||
|
lines.append(f' - Example: "{q[:100]}..."' if len(q) > 100 else f' - Example: "{q}"')
|
||||||
|
return "\n".join(lines) or "No issues found"
|
||||||
|
|
||||||
|
def _format_strengths(self, strengths: list[dict]) -> str:
|
||||||
|
"""Format strengths for prompt."""
|
||||||
|
lines = []
|
||||||
|
for i, strength in enumerate(strengths[:3], 1):
|
||||||
|
subcode = strength.get("subcode", "")
|
||||||
|
name = strength.get("subcode_name", "")
|
||||||
|
domain = strength.get("domain_name", "")
|
||||||
|
count = strength.get("positive_count", 0)
|
||||||
|
quotes = strength.get("example_quotes", [])[:2]
|
||||||
|
|
||||||
|
lines.append(f"{i}. [{subcode}] {name} ({domain})")
|
||||||
|
lines.append(f" - {count} positive mentions")
|
||||||
|
for q in quotes:
|
||||||
|
if q:
|
||||||
|
lines.append(f' - Example: "{q[:100]}..."' if len(q) > 100 else f' - Example: "{q}"')
|
||||||
|
return "\n".join(lines) or "No strengths found"
|
||||||
|
|
||||||
|
def _format_domains(self, domains: list[dict]) -> str:
|
||||||
|
"""Format domain distribution for prompt."""
|
||||||
|
lines = []
|
||||||
|
for d in domains:
|
||||||
|
domain = d.get("domain", "")
|
||||||
|
name = d.get("domain_name", "")
|
||||||
|
total = d.get("total_count", 0)
|
||||||
|
positive = d.get("positive_count", 0)
|
||||||
|
negative = d.get("negative_count", 0)
|
||||||
|
lines.append(f"- {domain} ({name}): {total} total ({positive} positive, {negative} negative)")
|
||||||
|
return "\n".join(lines) or "No domain data"
|
||||||
|
|
||||||
|
def _create_fallback_synthesis(self) -> Synthesis:
|
||||||
|
"""Create a minimal synthesis when LLM fails."""
|
||||||
|
return Synthesis(
|
||||||
|
executive_narrative="Unable to generate detailed analysis. Please review the data manually.",
|
||||||
|
sentiment_insight="",
|
||||||
|
category_insight="",
|
||||||
|
timeline_insight="",
|
||||||
|
priority_domain=None,
|
||||||
|
priority_issue=None,
|
||||||
|
action_plan=[],
|
||||||
|
issue_actions={},
|
||||||
|
timeline_annotations=[],
|
||||||
|
marketing_angles=[],
|
||||||
|
competitor_context=None,
|
||||||
|
generated_at=datetime.utcnow().isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _store_synthesis(self, execution_id: str, synthesis: Synthesis) -> None:
|
||||||
|
"""Store synthesis in database."""
|
||||||
|
await self.pool.execute("""
|
||||||
|
UPDATE pipeline.executions
|
||||||
|
SET
|
||||||
|
synthesis = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE execution_id = $1
|
||||||
|
""", execution_id, json.dumps({
|
||||||
|
"executive_narrative": synthesis.executive_narrative,
|
||||||
|
"sentiment_insight": synthesis.sentiment_insight,
|
||||||
|
"category_insight": synthesis.category_insight,
|
||||||
|
"timeline_insight": synthesis.timeline_insight,
|
||||||
|
"priority_domain": synthesis.priority_domain,
|
||||||
|
"priority_issue": synthesis.priority_issue,
|
||||||
|
"action_plan": [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"title": a.title,
|
||||||
|
"why": a.why,
|
||||||
|
"what": a.what,
|
||||||
|
"who": a.who,
|
||||||
|
"impact": a.impact,
|
||||||
|
"evidence": a.evidence,
|
||||||
|
"estimated_rating_lift": a.estimated_rating_lift,
|
||||||
|
"complexity": a.complexity,
|
||||||
|
"priority": a.priority,
|
||||||
|
"timeline": a.timeline,
|
||||||
|
"related_subcode": a.related_subcode,
|
||||||
|
}
|
||||||
|
for a in synthesis.action_plan
|
||||||
|
],
|
||||||
|
"issue_actions": synthesis.issue_actions,
|
||||||
|
"timeline_annotations": [
|
||||||
|
{
|
||||||
|
"date": t.date,
|
||||||
|
"label": t.label,
|
||||||
|
"description": t.description,
|
||||||
|
"type": t.type,
|
||||||
|
}
|
||||||
|
for t in synthesis.timeline_annotations
|
||||||
|
],
|
||||||
|
"marketing_angles": synthesis.marketing_angles,
|
||||||
|
"competitor_context": synthesis.competitor_context,
|
||||||
|
"generated_at": synthesis.generated_at,
|
||||||
|
}))
|
||||||
74
web/components/reviewiq/DashboardSkeleton.tsx
Normal file
74
web/components/reviewiq/DashboardSkeleton.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton for the ReviewIQ Dashboard.
|
||||||
|
*/
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-pulse">
|
||||||
|
{/* KPI Cards Skeleton */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-gray-200 rounded-xl h-28"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Grid Skeleton */}
|
||||||
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-gray-200 rounded-xl h-80" />
|
||||||
|
<div className="bg-gray-200 rounded-xl h-80" />
|
||||||
|
<div className="bg-gray-200 rounded-xl h-80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Skeleton */}
|
||||||
|
<div className="bg-gray-200 rounded-xl h-96" />
|
||||||
|
|
||||||
|
{/* Tables Skeleton */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-gray-200 rounded-xl h-80" />
|
||||||
|
<div className="bg-gray-200 rounded-xl h-80" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error state component.
|
||||||
|
*/
|
||||||
|
export function DashboardError({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 bg-red-50 rounded-xl border-2 border-red-200">
|
||||||
|
<div className="text-red-600 text-lg font-semibold mb-2">
|
||||||
|
Failed to load dashboard
|
||||||
|
</div>
|
||||||
|
<p className="text-red-500 text-sm mb-4">{message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state when no job is selected.
|
||||||
|
*/
|
||||||
|
export function DashboardEmpty() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 bg-gray-50 rounded-xl border-2 border-gray-200">
|
||||||
|
<div className="text-gray-600 text-lg font-semibold mb-2">
|
||||||
|
No Job Selected
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
Select a job to view analytics or run the ReviewIQ pipeline first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
web/components/reviewiq/FilterBar.tsx
Normal file
128
web/components/reviewiq/FilterBar.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X, Filter } from 'lucide-react';
|
||||||
|
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||||
|
import { DOMAIN_LABELS, INTENSITY_LABELS, TimeRange } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter bar showing active filters with clear button.
|
||||||
|
*/
|
||||||
|
export function FilterBar() {
|
||||||
|
const {
|
||||||
|
filters,
|
||||||
|
toggleSentiment,
|
||||||
|
setURTDomain,
|
||||||
|
toggleIntensity,
|
||||||
|
setTimeRange,
|
||||||
|
setBrushRange,
|
||||||
|
clearFilters,
|
||||||
|
hasActiveFilters,
|
||||||
|
} = useReviewIQFilters();
|
||||||
|
|
||||||
|
if (!hasActiveFilters) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeRangeLabels: Record<TimeRange, string> = {
|
||||||
|
'7d': 'Last 7 days',
|
||||||
|
'14d': 'Last 14 days',
|
||||||
|
'30d': 'Last 30 days',
|
||||||
|
'90d': 'Last 90 days',
|
||||||
|
'1y': 'Last year',
|
||||||
|
'all': 'All time',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 border-2 border-blue-200 rounded-xl p-4 flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Filter className="w-5 h-5 text-blue-700" />
|
||||||
|
<span className="font-semibold text-blue-900">Active Filters:</span>
|
||||||
|
|
||||||
|
{/* Time Range (only show if not default) */}
|
||||||
|
{filters.timeRange !== '30d' && (
|
||||||
|
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium flex items-center gap-2 border border-blue-300">
|
||||||
|
{timeRangeLabels[filters.timeRange]}
|
||||||
|
<button
|
||||||
|
onClick={() => setTimeRange('30d')}
|
||||||
|
className="hover:bg-blue-200 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Brush Range */}
|
||||||
|
{filters.brushRange && (
|
||||||
|
<span className="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm font-medium flex items-center gap-2 border border-purple-300">
|
||||||
|
{filters.brushRange.start} to {filters.brushRange.end}
|
||||||
|
<button
|
||||||
|
onClick={() => setBrushRange(null)}
|
||||||
|
className="hover:bg-purple-200 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sentiment Filters */}
|
||||||
|
{filters.sentiment.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium flex items-center gap-2 border ${
|
||||||
|
s === 'positive'
|
||||||
|
? 'bg-green-100 text-green-800 border-green-300'
|
||||||
|
: s === 'negative'
|
||||||
|
? 'bg-red-100 text-red-800 border-red-300'
|
||||||
|
: 'bg-yellow-100 text-yellow-800 border-yellow-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleSentiment(s)}
|
||||||
|
className="hover:bg-opacity-50 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* URT Domain */}
|
||||||
|
{filters.urtDomain && (
|
||||||
|
<span className="px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm font-medium flex items-center gap-2 border border-indigo-300">
|
||||||
|
{DOMAIN_LABELS[filters.urtDomain] || filters.urtDomain}
|
||||||
|
<button
|
||||||
|
onClick={() => setURTDomain(null)}
|
||||||
|
className="hover:bg-indigo-200 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Intensity Filters */}
|
||||||
|
{filters.intensity.map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium flex items-center gap-2 border border-orange-300"
|
||||||
|
>
|
||||||
|
{INTENSITY_LABELS[i] || i} Intensity
|
||||||
|
<button
|
||||||
|
onClick={() => toggleIntensity(i)}
|
||||||
|
className="hover:bg-orange-200 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -112,6 +112,7 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
|||||||
avgRating={data.overview.avg_rating}
|
avgRating={data.overview.avg_rating}
|
||||||
domainScores={data.domain_scores}
|
domainScores={data.domain_scores}
|
||||||
onDomainClick={handleDomainClick}
|
onDomainClick={handleDomainClick}
|
||||||
|
synthesis={data.synthesis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════
|
||||||
@@ -119,8 +120,15 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
|||||||
Side-by-side: How customers feel + What they talk about
|
Side-by-side: How customers feel + What they talk about
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════════════════════ */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<SentimentPie data={data.sentiment.distribution} />
|
<SentimentPie
|
||||||
<IntensityHeatmap data={data.urt.domains} />
|
data={data.sentiment.distribution}
|
||||||
|
insight={data.synthesis?.sentiment_insight}
|
||||||
|
/>
|
||||||
|
<IntensityHeatmap
|
||||||
|
data={data.urt.domains}
|
||||||
|
insight={data.synthesis?.category_insight}
|
||||||
|
highlightDomain={data.synthesis?.priority_domain}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════
|
||||||
@@ -133,7 +141,11 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
|||||||
SECTION 4: TRENDS (Timeline)
|
SECTION 4: TRENDS (Timeline)
|
||||||
How things change over time
|
How things change over time
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
═══════════════════════════════════════════════════════════════ */}
|
||||||
<TimelineChart data={data.timeline} />
|
<TimelineChart
|
||||||
|
data={data.timeline}
|
||||||
|
insight={data.synthesis?.timeline_insight}
|
||||||
|
annotations={data.synthesis?.timeline_annotations}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* ═══════════════════════════════════════════════════════════════
|
||||||
SECTION 5: DEEP DIVE (Tables)
|
SECTION 5: DEEP DIVE (Tables)
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
|||||||
|
|
||||||
interface SentimentHeatmapProps {
|
interface SentimentHeatmapProps {
|
||||||
data: URTDomainPoint[];
|
data: URTDomainPoint[];
|
||||||
|
// AI-generated insight (optional - shows when available)
|
||||||
|
insight?: string | null;
|
||||||
|
// Domain to highlight (optional - from AI priority)
|
||||||
|
highlightDomain?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User-friendly domain config with emojis and descriptions
|
// User-friendly domain config with emojis and descriptions
|
||||||
@@ -51,7 +55,7 @@ const getNegativeColor = (value: number, max: number): string => {
|
|||||||
* User-friendly design with emojis and clear labels.
|
* User-friendly design with emojis and clear labels.
|
||||||
* Click to filter by domain and sentiment.
|
* Click to filter by domain and sentiment.
|
||||||
*/
|
*/
|
||||||
export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
|
export function IntensityHeatmap({ data, insight, highlightDomain }: SentimentHeatmapProps) {
|
||||||
const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters();
|
const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters();
|
||||||
|
|
||||||
// Check if cross-filters are active
|
// Check if cross-filters are active
|
||||||
@@ -136,6 +140,19 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Insight (when available) */}
|
||||||
|
{insight && (
|
||||||
|
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">✨</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
|
||||||
|
<p className="text-sm text-gray-700">{insight}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||||
No feedback data available
|
No feedback data available
|
||||||
@@ -170,12 +187,15 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
|
|||||||
const isDomainActive = filters.urtDomain === row.domain;
|
const isDomainActive = filters.urtDomain === row.domain;
|
||||||
const isPositiveActive = isDomainActive && filters.sentiment.includes('positive');
|
const isPositiveActive = isDomainActive && filters.sentiment.includes('positive');
|
||||||
const isNegativeActive = isDomainActive && filters.sentiment.includes('negative');
|
const isNegativeActive = isDomainActive && filters.sentiment.includes('negative');
|
||||||
|
const isHighlighted = highlightDomain === row.domain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={row.domain}
|
key={row.domain}
|
||||||
className={`border-t border-gray-100 transition-colors ${
|
className={`border-t border-gray-100 transition-colors ${
|
||||||
isDomainActive ? 'bg-blue-50' : 'hover:bg-gray-50'
|
isDomainActive ? 'bg-blue-50' :
|
||||||
|
isHighlighted ? 'bg-purple-50 ring-1 ring-purple-300' :
|
||||||
|
'hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Domain Label */}
|
{/* Domain Label */}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
|||||||
|
|
||||||
interface SentimentPieProps {
|
interface SentimentPieProps {
|
||||||
data: SentimentDataPoint[];
|
data: SentimentDataPoint[];
|
||||||
|
// AI-generated insight (optional - shows when available)
|
||||||
|
insight?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User-friendly sentiment config
|
// User-friendly sentiment config
|
||||||
@@ -74,7 +76,7 @@ const SENTIMENT_ORDER = ['V+', 'V-', 'V0', 'V±'];
|
|||||||
* User-friendly design with emojis and clear numbers.
|
* User-friendly design with emojis and clear numbers.
|
||||||
* Click to filter by sentiment.
|
* Click to filter by sentiment.
|
||||||
*/
|
*/
|
||||||
export function SentimentPie({ data }: SentimentPieProps) {
|
export function SentimentPie({ data, insight }: SentimentPieProps) {
|
||||||
const { filters, toggleSentiment } = useReviewIQFilters();
|
const { filters, toggleSentiment } = useReviewIQFilters();
|
||||||
|
|
||||||
// Process data
|
// Process data
|
||||||
@@ -185,6 +187,19 @@ export function SentimentPie({ data }: SentimentPieProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Insight (when available) */}
|
||||||
|
{insight && (
|
||||||
|
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">✨</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
|
||||||
|
<p className="text-sm text-gray-700">{insight}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{processedData.cards.length === 0 ? (
|
{processedData.cards.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||||
No sentiment data available
|
No sentiment data available
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import {
|
|||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
|
import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
|
||||||
import type { TimelinePoint, TimeRange } from '../types';
|
import type { TimelinePoint, TimeRange, TimelineAnnotation } from '../types';
|
||||||
import { DOMAIN_LABELS } from '../types';
|
import { DOMAIN_LABELS } from '../types';
|
||||||
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||||
|
|
||||||
interface TimelineChartProps {
|
interface TimelineChartProps {
|
||||||
data: TimelinePoint[];
|
data: TimelinePoint[];
|
||||||
|
// AI-generated insight (optional - shows when available)
|
||||||
|
insight?: string | null;
|
||||||
|
// Timeline annotations from AI (optional - marks key events)
|
||||||
|
annotations?: TimelineAnnotation[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewMode = 'sentiment' | 'volume' | 'rating';
|
type ViewMode = 'sentiment' | 'volume' | 'rating';
|
||||||
@@ -45,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string
|
|||||||
* User-friendly design with view toggles and interactive brush.
|
* User-friendly design with view toggles and interactive brush.
|
||||||
* Responds to domain/sentiment filters.
|
* Responds to domain/sentiment filters.
|
||||||
*/
|
*/
|
||||||
export function TimelineChart({ data }: TimelineChartProps) {
|
export function TimelineChart({ data, insight, annotations }: TimelineChartProps) {
|
||||||
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
|
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
|
const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
|
||||||
const [localBrushRange, setLocalBrushRange] = useState<{
|
const [localBrushRange, setLocalBrushRange] = useState<{
|
||||||
@@ -264,6 +268,44 @@ export function TimelineChart({ data }: TimelineChartProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI Insight (when available) */}
|
||||||
|
{insight && (
|
||||||
|
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-sm">✨</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
|
||||||
|
<p className="text-sm text-gray-700">{insight}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key Events (when annotations available) */}
|
||||||
|
{annotations && annotations.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{annotations.slice(0, 3).map((annotation, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1 ${
|
||||||
|
annotation.type === 'positive' ? 'bg-green-100 text-green-700' :
|
||||||
|
annotation.type === 'negative' ? 'bg-red-100 text-red-700' :
|
||||||
|
annotation.type === 'event' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
'bg-gray-100 text-gray-700'
|
||||||
|
}`}
|
||||||
|
title={annotation.description}
|
||||||
|
>
|
||||||
|
<span>{
|
||||||
|
annotation.type === 'positive' ? '📈' :
|
||||||
|
annotation.type === 'negative' ? '📉' :
|
||||||
|
annotation.type === 'event' ? '📍' : '•'
|
||||||
|
}</span>
|
||||||
|
<span>{annotation.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sortedData.length === 0 ? (
|
{sortedData.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-80 text-gray-500">
|
<div className="flex flex-col items-center justify-center h-80 text-gray-500">
|
||||||
<Calendar className="w-12 h-12 text-gray-300 mb-2" />
|
<Calendar className="w-12 h-12 text-gray-300 mb-2" />
|
||||||
|
|||||||
36
web/components/reviewiq/index.ts
Normal file
36
web/components/reviewiq/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* ReviewIQ Dashboard Components
|
||||||
|
* Export all components for easy imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Main dashboard
|
||||||
|
export { ReviewIQDashboard } from './ReviewIQDashboard';
|
||||||
|
|
||||||
|
// Supporting components
|
||||||
|
export { FilterBar } from './FilterBar';
|
||||||
|
export { DashboardSkeleton, DashboardError, DashboardEmpty } from './DashboardSkeleton';
|
||||||
|
|
||||||
|
// KPI components
|
||||||
|
export { KPICard } from './kpi/KPICard';
|
||||||
|
export { KPISection } from './kpi/KPISection';
|
||||||
|
export { DomainScores } from './kpi/DomainScores';
|
||||||
|
|
||||||
|
// Insights components
|
||||||
|
export { ExecutiveSummary } from './insights/ExecutiveSummary';
|
||||||
|
export { StrengthsWeaknesses } from './insights/StrengthsWeaknesses';
|
||||||
|
export { OpportunityMatrix } from './insights/OpportunityMatrix';
|
||||||
|
export { RatingSimulator } from './insights/RatingSimulator';
|
||||||
|
|
||||||
|
// Chart components
|
||||||
|
export { SentimentPie } from './charts/SentimentPie';
|
||||||
|
export { URTBarChart } from './charts/URTBarChart';
|
||||||
|
export { IntensityHeatmap } from './charts/IntensityHeatmap';
|
||||||
|
export { TimelineChart } from './charts/TimelineChart';
|
||||||
|
|
||||||
|
// Table components
|
||||||
|
export { IssuesTable } from './tables/IssuesTable';
|
||||||
|
export { IssueDetailModal } from './tables/IssueDetailModal';
|
||||||
|
export { SpansTable } from './tables/SpansTable';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Award,
|
Award,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types';
|
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types';
|
||||||
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
||||||
|
|
||||||
interface ExecutiveSummaryProps {
|
interface ExecutiveSummaryProps {
|
||||||
@@ -25,6 +25,8 @@ interface ExecutiveSummaryProps {
|
|||||||
domainScores?: DomainScore[];
|
domainScores?: DomainScore[];
|
||||||
onDriverClick?: (subcode: string) => void;
|
onDriverClick?: (subcode: string) => void;
|
||||||
onDomainClick?: (domain: URTDomain) => void;
|
onDomainClick?: (domain: URTDomain) => void;
|
||||||
|
// AI-generated narrative (optional - enhances when available)
|
||||||
|
synthesis?: Synthesis | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// User-friendly domain config
|
// User-friendly domain config
|
||||||
@@ -199,10 +201,14 @@ export function ExecutiveSummary({
|
|||||||
domainScores,
|
domainScores,
|
||||||
onDriverClick,
|
onDriverClick,
|
||||||
onDomainClick,
|
onDomainClick,
|
||||||
|
synthesis,
|
||||||
}: ExecutiveSummaryProps) {
|
}: ExecutiveSummaryProps) {
|
||||||
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
|
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
|
||||||
const [showFullSummary, setShowFullSummary] = useState(false);
|
const [showFullSummary, setShowFullSummary] = useState(false);
|
||||||
|
|
||||||
|
// Use AI narrative if available, otherwise fall back to generated summary
|
||||||
|
const narrativeText = synthesis?.executive_narrative || executive_summary;
|
||||||
|
|
||||||
const topStrength = strengths[0];
|
const topStrength = strengths[0];
|
||||||
const topWeakness = weaknesses[0];
|
const topWeakness = weaknesses[0];
|
||||||
const ratingDisplay = getRatingDisplay(avgRating);
|
const ratingDisplay = getRatingDisplay(avgRating);
|
||||||
@@ -286,16 +292,23 @@ export function ExecutiveSummary({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Summary */}
|
{/* AI Summary */}
|
||||||
{executive_summary && (
|
{narrativeText && (
|
||||||
<div className="px-6 pb-4">
|
<div className="px-6 pb-4">
|
||||||
<div className="p-4 bg-white/70 rounded-xl border border-blue-100">
|
<div className={`p-4 rounded-xl border ${
|
||||||
|
synthesis?.executive_narrative
|
||||||
|
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
|
||||||
|
: 'bg-white/70 border-blue-100'
|
||||||
|
}`}>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="text-lg">💡</span>
|
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-2'}`}>
|
{synthesis?.executive_narrative && (
|
||||||
{executive_summary}
|
<div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
|
||||||
|
)}
|
||||||
|
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
|
||||||
|
{narrativeText}
|
||||||
</p>
|
</p>
|
||||||
{executive_summary.length > 150 && (
|
{narrativeText.length > 200 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFullSummary(!showFullSummary)}
|
onClick={() => setShowFullSummary(!showFullSummary)}
|
||||||
className="text-blue-600 text-sm font-medium mt-1 hover:underline"
|
className="text-blue-600 text-sm font-medium mt-1 hover:underline"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
204
web/components/reviewiq/insights/RatingSimulator.tsx
Normal file
204
web/components/reviewiq/insights/RatingSimulator.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Star, TrendingUp, Award, Zap } from 'lucide-react';
|
||||||
|
import type { RatingSimulator as RatingSimulatorType, WeaknessItem } from '../types';
|
||||||
|
|
||||||
|
interface RatingSimulatorProps {
|
||||||
|
simulator: RatingSimulatorType | null;
|
||||||
|
topWeaknesses?: WeaknessItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RatingStarProps {
|
||||||
|
rating: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
isProjected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingDisplay({ rating, label, color, isProjected }: RatingStarProps) {
|
||||||
|
const fullStars = Math.floor(rating);
|
||||||
|
const partialStar = rating - fullStars;
|
||||||
|
const emptyStars = 5 - Math.ceil(rating);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xs font-semibold text-gray-500 mb-1">{label}</div>
|
||||||
|
<div className="flex items-center justify-center gap-0.5 mb-1">
|
||||||
|
{/* Full stars */}
|
||||||
|
{Array.from({ length: fullStars }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={`full-${i}`}
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ fill: color, stroke: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Partial star */}
|
||||||
|
{partialStar > 0 && (
|
||||||
|
<div className="relative w-5 h-5">
|
||||||
|
<Star className="absolute w-5 h-5 text-gray-300" />
|
||||||
|
<div
|
||||||
|
className="absolute overflow-hidden"
|
||||||
|
style={{ width: `${partialStar * 100}%` }}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ fill: color, stroke: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Empty stars */}
|
||||||
|
{Array.from({ length: emptyStars }).map((_, i) => (
|
||||||
|
<Star key={`empty-${i}`} className="w-5 h-5 text-gray-300" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-bold ${isProjected ? 'text-green-600' : 'text-gray-900'}`}>
|
||||||
|
{rating.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rating simulator showing potential rating improvements.
|
||||||
|
*/
|
||||||
|
export function RatingSimulator({ simulator, topWeaknesses = [] }: RatingSimulatorProps) {
|
||||||
|
if (!simulator || simulator.potential_gain <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current_rating, if_fix_top_1, if_fix_top_3, potential_gain } = simulator;
|
||||||
|
|
||||||
|
// Calculate progress towards 5 stars
|
||||||
|
const currentProgress = (current_rating / 5) * 100;
|
||||||
|
const potentialProgress = ((current_rating + potential_gain) / 5) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-yellow-50 via-white to-green-50 rounded-xl p-6 shadow-md border-2 border-yellow-200">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="p-2 bg-yellow-100 rounded-lg">
|
||||||
|
<Star className="w-5 h-5 text-yellow-600" fill="#ca8a04" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Rating Simulator</h3>
|
||||||
|
<span className="ml-auto flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 text-sm font-bold rounded-full">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
+{potential_gain.toFixed(2)} potential
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating Comparisons */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<RatingDisplay
|
||||||
|
rating={current_rating}
|
||||||
|
label="Current"
|
||||||
|
color="#eab308"
|
||||||
|
/>
|
||||||
|
{if_fix_top_1 && (
|
||||||
|
<RatingDisplay
|
||||||
|
rating={if_fix_top_1}
|
||||||
|
label="Fix #1 Issue"
|
||||||
|
color="#22c55e"
|
||||||
|
isProjected
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{if_fix_top_3 && (
|
||||||
|
<RatingDisplay
|
||||||
|
rating={if_fix_top_3}
|
||||||
|
label="Fix Top 3"
|
||||||
|
color="#10b981"
|
||||||
|
isProjected
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||||
|
<span>Progress to 5 Stars</span>
|
||||||
|
<span>{Math.min(100, potentialProgress).toFixed(0)}% achievable</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded-full overflow-hidden relative">
|
||||||
|
{/* Current rating progress */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-gradient-to-r from-yellow-400 to-yellow-500 transition-all duration-500"
|
||||||
|
style={{ width: `${currentProgress}%` }}
|
||||||
|
/>
|
||||||
|
{/* Potential gain overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-gradient-to-r from-green-400/50 to-green-500/50 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
left: `${currentProgress}%`,
|
||||||
|
width: `${Math.min(100 - currentProgress, (potential_gain / 5) * 100)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs mt-1">
|
||||||
|
<span className="text-gray-400">1</span>
|
||||||
|
<span className="text-gray-400">2</span>
|
||||||
|
<span className="text-gray-400">3</span>
|
||||||
|
<span className="text-gray-400">4</span>
|
||||||
|
<span className="text-gray-400">5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Items */}
|
||||||
|
{topWeaknesses.length > 0 && (
|
||||||
|
<div className="border-t border-yellow-200 pt-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Zap className="w-4 h-4 text-orange-500" />
|
||||||
|
<span className="text-sm font-semibold text-gray-700">Priority Fixes</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topWeaknesses.slice(0, 3).map((weakness, index) => (
|
||||||
|
<div
|
||||||
|
key={weakness.subcode}
|
||||||
|
className="flex items-center gap-2 p-2 bg-white rounded-lg border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||||
|
index === 0 ? 'bg-red-100 text-red-700' :
|
||||||
|
index === 1 ? 'bg-orange-100 text-orange-700' :
|
||||||
|
'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate block">
|
||||||
|
{weakness.subcode_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{weakness.negative_percentage.toFixed(0)}% negative
|
||||||
|
{weakness.projected_rating_impact && (
|
||||||
|
<span className="ml-2 text-green-600 font-semibold">
|
||||||
|
+{weakness.projected_rating_impact.toFixed(2)} if fixed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{weakness.solution_complexity && (
|
||||||
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
|
||||||
|
weakness.solution_complexity === 'simple' ? 'bg-green-100 text-green-700' :
|
||||||
|
weakness.solution_complexity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{weakness.solution_complexity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-4 p-3 bg-green-50 rounded-lg border border-green-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="w-5 h-5 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-green-800">
|
||||||
|
Fixing the top 3 issues could boost your rating by{' '}
|
||||||
|
<span className="font-bold">{((if_fix_top_3 || current_rating) - current_rating).toFixed(2)} stars</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
web/components/reviewiq/insights/StrengthsWeaknesses.tsx
Normal file
185
web/components/reviewiq/insights/StrengthsWeaknesses.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TrendingUp, TrendingDown, Lightbulb, Target, Megaphone, User } from 'lucide-react';
|
||||||
|
import type { StrengthItem, WeaknessItem } from '../types';
|
||||||
|
import { DOMAIN_COLORS, COMPLEXITY_LABELS } from '../types';
|
||||||
|
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
||||||
|
|
||||||
|
interface StrengthsWeaknessesProps {
|
||||||
|
strengths: StrengthItem[];
|
||||||
|
weaknesses: WeaknessItem[];
|
||||||
|
onStrengthClick?: (subcode: string) => void;
|
||||||
|
onWeaknessClick?: (subcode: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StrengthsWeaknesses({
|
||||||
|
strengths,
|
||||||
|
weaknesses,
|
||||||
|
onStrengthClick,
|
||||||
|
onWeaknessClick,
|
||||||
|
}: StrengthsWeaknessesProps) {
|
||||||
|
const hasData = strengths.length > 0 || weaknesses.length > 0;
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center text-gray-500">
|
||||||
|
<p>Not enough data to identify strengths and weaknesses.</p>
|
||||||
|
<p className="text-sm mt-1">More reviews are needed for analysis.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* Strengths Panel */}
|
||||||
|
<div className="bg-white rounded-lg border border-green-200 overflow-hidden">
|
||||||
|
<div className="bg-green-50 px-4 py-3 border-b border-green-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||||
|
<h3 className="font-semibold text-green-800">Your Strengths</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-green-600 mt-0.5">Protect & amplify these</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-green-100">
|
||||||
|
{strengths.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">
|
||||||
|
No strong positive patterns detected yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
strengths.map((strength) => (
|
||||||
|
<button
|
||||||
|
key={strength.subcode}
|
||||||
|
onClick={() => onStrengthClick?.(strength.subcode)}
|
||||||
|
className="w-full p-3 text-left hover:bg-green-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{strength.rank}. {strength.subcode_name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${DOMAIN_COLORS[strength.domain]}20`,
|
||||||
|
color: DOMAIN_COLORS[strength.domain],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strength.domain}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic">
|
||||||
|
{getSubcodeDefinition(strength.subcode) || strength.subcode_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{strength.positive_percentage.toFixed(0)}% positive, {strength.span_count} mentions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-green-600 font-bold text-lg">
|
||||||
|
{strength.positive_percentage.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{strength.marketing_angle && (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5 text-sm text-green-700 bg-green-50 rounded p-2">
|
||||||
|
<Megaphone className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{strength.marketing_angle}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weaknesses Panel */}
|
||||||
|
<div className="bg-white rounded-lg border border-red-200 overflow-hidden">
|
||||||
|
<div className="bg-red-50 px-4 py-3 border-b border-red-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingDown className="w-5 h-5 text-red-600" />
|
||||||
|
<h3 className="font-semibold text-red-800">Areas to Improve</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-red-600 mt-0.5">Fix these to boost rating</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-red-100">
|
||||||
|
{weaknesses.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">
|
||||||
|
No significant issues detected. Great job!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
weaknesses.map((weakness) => (
|
||||||
|
<button
|
||||||
|
key={weakness.subcode}
|
||||||
|
onClick={() => onWeaknessClick?.(weakness.subcode)}
|
||||||
|
className="w-full p-3 text-left hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{weakness.rank}. {weakness.subcode_name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${DOMAIN_COLORS[weakness.domain]}20`,
|
||||||
|
color: DOMAIN_COLORS[weakness.domain],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{weakness.domain}
|
||||||
|
</span>
|
||||||
|
{weakness.intensity === 'I3' && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">
|
||||||
|
High Intensity
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 italic">
|
||||||
|
{getSubcodeDefinition(weakness.subcode) || weakness.subcode_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{weakness.negative_percentage.toFixed(0)}% negative, {weakness.span_count} mentions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-red-600 font-bold text-lg">
|
||||||
|
{weakness.negative_percentage.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
{weakness.projected_rating_impact && (
|
||||||
|
<div className="text-xs text-green-600">
|
||||||
|
+{weakness.projected_rating_impact.toFixed(2)} if fixed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weakness.solution && (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5 text-sm text-amber-700 bg-amber-50 rounded p-2">
|
||||||
|
<Lightbulb className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{weakness.solution}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{weakness.owner && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
<span>Owner: {weakness.owner}</span>
|
||||||
|
{weakness.solution_complexity && (
|
||||||
|
<>
|
||||||
|
<span className="mx-1">|</span>
|
||||||
|
<Target className="w-3 h-3" />
|
||||||
|
<span>{COMPLEXITY_LABELS[weakness.solution_complexity] || weakness.solution_complexity}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
web/components/reviewiq/insights/index.ts
Normal file
4
web/components/reviewiq/insights/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ExecutiveSummary } from './ExecutiveSummary';
|
||||||
|
export { StrengthsWeaknesses } from './StrengthsWeaknesses';
|
||||||
|
export { OpportunityMatrix } from './OpportunityMatrix';
|
||||||
|
export { RatingSimulator } from './RatingSimulator';
|
||||||
261
web/components/reviewiq/kpi/DomainScores.tsx
Normal file
261
web/components/reviewiq/kpi/DomainScores.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ThumbsUp, ThumbsDown, MessageSquare, Info } from 'lucide-react';
|
||||||
|
import type { DomainScore, URTDomain } from '../types';
|
||||||
|
import { DOMAIN_COLORS, DOMAIN_LABELS } from '../types';
|
||||||
|
|
||||||
|
interface DomainScoresProps {
|
||||||
|
scores: DomainScore[];
|
||||||
|
overallIndex: number | null;
|
||||||
|
onDomainClick?: (domain: URTDomain) => void;
|
||||||
|
activeDomain?: URTDomain | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain descriptions explaining what each measures
|
||||||
|
const DOMAIN_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
O: 'Product/service quality, features, and reliability',
|
||||||
|
P: 'Staff attitude, helpfulness, and professionalism',
|
||||||
|
J: 'Are appointments on time? Is the process smooth?',
|
||||||
|
E: 'Physical space, ambiance, cleanliness, and safety',
|
||||||
|
A: 'Can you get there? Location, open hours, parking',
|
||||||
|
V: 'Pricing fairness, value for money, and billing clarity',
|
||||||
|
R: 'Trust, consistency, care, and problem recovery',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get color based on score value
|
||||||
|
function getScoreColor(score: number): string {
|
||||||
|
if (score >= 70) return '#22c55e'; // green-500
|
||||||
|
if (score >= 50) return '#eab308'; // yellow-500
|
||||||
|
return '#ef4444'; // red-500
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get background color (lighter) based on score value
|
||||||
|
function getScoreBgColor(score: number): string {
|
||||||
|
if (score >= 70) return '#dcfce7'; // green-100
|
||||||
|
if (score >= 50) return '#fef9c3'; // yellow-100
|
||||||
|
return '#fee2e2'; // red-100
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status label
|
||||||
|
function getStatusLabel(score: number): string {
|
||||||
|
if (score >= 70) return 'Good';
|
||||||
|
if (score >= 50) return 'Needs Work';
|
||||||
|
return 'Critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get emoji for status
|
||||||
|
function getStatusEmoji(score: number): string {
|
||||||
|
if (score >= 70) return '✓';
|
||||||
|
if (score >= 50) return '!';
|
||||||
|
return '✗';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainScores({
|
||||||
|
scores,
|
||||||
|
overallIndex,
|
||||||
|
onDomainClick,
|
||||||
|
activeDomain,
|
||||||
|
}: DomainScoresProps) {
|
||||||
|
// Sort scores by domain order: O, P, J, E, A, V, R
|
||||||
|
const domainOrder = ['O', 'P', 'J', 'E', 'A', 'V', 'R'];
|
||||||
|
const sortedScores = [...scores].sort(
|
||||||
|
(a, b) => domainOrder.indexOf(a.domain) - domainOrder.indexOf(b.domain)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalPositive = scores.reduce((sum, s) => sum + s.positive_count, 0);
|
||||||
|
const totalNegative = scores.reduce((sum, s) => sum + s.negative_count, 0);
|
||||||
|
const totalMentions = scores.reduce((sum, s) => sum + s.total_count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border-2 border-gray-200 p-5">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="flex items-start justify-between mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-gray-900 mb-1">What Customers Talk About</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
How you're performing in each area of the customer experience
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Experience Index */}
|
||||||
|
{overallIndex !== null && (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center px-4 py-2 rounded-xl"
|
||||||
|
style={{ backgroundColor: getScoreBgColor(overallIndex) }}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-gray-500 font-medium">
|
||||||
|
Overall Score
|
||||||
|
</span>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span
|
||||||
|
className="text-3xl font-bold"
|
||||||
|
style={{ color: getScoreColor(overallIndex) }}
|
||||||
|
>
|
||||||
|
{overallIndex.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">/100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-5 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-green-100 rounded">
|
||||||
|
<ThumbsUp className="w-4 h-4 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-green-600">{totalPositive}</div>
|
||||||
|
<div className="text-[10px] text-gray-500">Happy comments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-red-100 rounded">
|
||||||
|
<ThumbsDown className="w-4 h-4 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-red-600">{totalNegative}</div>
|
||||||
|
<div className="text-[10px] text-gray-500">Complaints</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 bg-blue-100 rounded">
|
||||||
|
<MessageSquare className="w-4 h-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-blue-600">{totalMentions}</div>
|
||||||
|
<div className="text-[10px] text-gray-500">Topics analyzed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How to read this */}
|
||||||
|
<div className="flex items-center gap-2 mb-3 p-2 bg-blue-50 rounded-lg border border-blue-100">
|
||||||
|
<Info className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||||
|
<p className="text-[11px] text-blue-700">
|
||||||
|
<strong>Score = % positive feedback.</strong> Higher is better. Based on {totalMentions.toLocaleString()} things customers mentioned in their reviews.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Score Cards */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sortedScores.map((score) => {
|
||||||
|
const isActive = activeDomain === score.domain;
|
||||||
|
const scoreColor = getScoreColor(score.score);
|
||||||
|
const scoreBg = getScoreBgColor(score.score);
|
||||||
|
const positiveRatio = score.total_count > 0
|
||||||
|
? Math.round((score.positive_count / score.total_count) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={score.domain}
|
||||||
|
onClick={() => onDomainClick?.(score.domain as URTDomain)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-4 p-3 rounded-xl transition-all text-left
|
||||||
|
hover:shadow-md cursor-pointer border-2
|
||||||
|
${isActive
|
||||||
|
? 'shadow-md border-current'
|
||||||
|
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
borderColor: isActive ? DOMAIN_COLORS[score.domain] : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Domain Badge */}
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center text-white font-bold text-lg flex-shrink-0 shadow-sm"
|
||||||
|
style={{ backgroundColor: DOMAIN_COLORS[score.domain] }}
|
||||||
|
>
|
||||||
|
{score.domain}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Domain Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
|
{DOMAIN_LABELS[score.domain] || score.name}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-gray-400 leading-tight">
|
||||||
|
{DOMAIN_DESCRIPTIONS[score.domain]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||||
|
<span
|
||||||
|
className="text-xl font-bold"
|
||||||
|
style={{ color: scoreColor }}
|
||||||
|
>
|
||||||
|
{score.score.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-1 rounded-lg font-semibold flex items-center gap-1"
|
||||||
|
style={{ backgroundColor: scoreBg, color: scoreColor }}
|
||||||
|
>
|
||||||
|
{getStatusEmoji(score.score)} {getStatusLabel(score.score)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar with threshold markers */}
|
||||||
|
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
{/* Threshold markers */}
|
||||||
|
<div className="absolute left-[50%] top-0 bottom-0 w-px bg-gray-300 z-10" />
|
||||||
|
<div className="absolute left-[70%] top-0 bottom-0 w-px bg-gray-300 z-10" />
|
||||||
|
|
||||||
|
{/* Score fill */}
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500 relative"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, Math.max(0, score.score))}%`,
|
||||||
|
backgroundColor: scoreColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics row */}
|
||||||
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
|
<div className="flex items-center gap-4 text-[11px]">
|
||||||
|
<span className="text-green-600 font-medium">
|
||||||
|
👍 {score.positive_count} positive
|
||||||
|
</span>
|
||||||
|
<span className="text-red-600 font-medium">
|
||||||
|
👎 {score.negative_count} negative
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{score.total_count} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Threshold Legend */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<div className="text-[10px] text-gray-400">
|
||||||
|
Click any area to filter the dashboard
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-[11px]">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4 h-2 rounded bg-red-500" />
|
||||||
|
<span className="text-gray-500"><50%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4 h-2 rounded bg-yellow-500" />
|
||||||
|
<span className="text-gray-500">50-69%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4 h-2 rounded bg-green-500" />
|
||||||
|
<span className="text-gray-500">≥70%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
web/components/reviewiq/kpi/KPICard.tsx
Normal file
63
web/components/reviewiq/kpi/KPICard.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface KPICardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
colorClass: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clickable KPI card component for the dashboard.
|
||||||
|
*/
|
||||||
|
export function KPICard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
colorClass,
|
||||||
|
onClick,
|
||||||
|
isActive = false,
|
||||||
|
}: KPICardProps) {
|
||||||
|
const baseClasses = `
|
||||||
|
rounded-xl p-4 shadow-md hover:shadow-lg transition-all cursor-pointer
|
||||||
|
border-2 ${colorClass}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const activeClasses = isActive
|
||||||
|
? 'ring-2 ring-offset-2 ring-blue-500 scale-[1.02]'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${baseClasses} ${activeClasses}`}
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
onClick?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="text-sm font-bold">{title}</span>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<span className="px-2 py-0.5 bg-blue-600 text-white text-[10px] font-bold rounded-full">
|
||||||
|
ACTIVE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold">{value}</div>
|
||||||
|
{subtitle && <div className="text-xs mt-1 font-medium opacity-80">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
web/components/reviewiq/kpi/KPISection.tsx
Normal file
120
web/components/reviewiq/kpi/KPISection.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
AlertTriangle,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Star,
|
||||||
|
Target,
|
||||||
|
Layers,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { KPICard } from './KPICard';
|
||||||
|
import type { OverviewStats, Sentiment } from '../types';
|
||||||
|
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||||
|
|
||||||
|
interface KPISectionProps {
|
||||||
|
overview: OverviewStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI cards section showing overview statistics.
|
||||||
|
* Cards are clickable to filter the dashboard.
|
||||||
|
*/
|
||||||
|
export function KPISection({ overview }: KPISectionProps) {
|
||||||
|
const { filters, toggleSentiment } = useReviewIQFilters();
|
||||||
|
|
||||||
|
const positiveActive = filters.sentiment.includes('positive');
|
||||||
|
const negativeActive = filters.sentiment.includes('negative');
|
||||||
|
|
||||||
|
const totalSentiment =
|
||||||
|
overview.positive_count + overview.negative_count + overview.neutral_count + overview.mixed_count;
|
||||||
|
|
||||||
|
const positivePercent =
|
||||||
|
totalSentiment > 0 ? ((overview.positive_count / totalSentiment) * 100).toFixed(0) : '0';
|
||||||
|
const negativePercent =
|
||||||
|
totalSentiment > 0 ? ((overview.negative_count / totalSentiment) * 100).toFixed(0) : '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{/* Total Reviews */}
|
||||||
|
<KPICard
|
||||||
|
title="Reviews"
|
||||||
|
value={overview.total_reviews}
|
||||||
|
subtitle="Total processed"
|
||||||
|
icon={MessageSquare}
|
||||||
|
colorClass="bg-gradient-to-br from-blue-100 to-blue-200 border-blue-400 text-blue-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Total Spans */}
|
||||||
|
<KPICard
|
||||||
|
title="Spans"
|
||||||
|
value={overview.total_spans}
|
||||||
|
subtitle="Classified segments"
|
||||||
|
icon={Layers}
|
||||||
|
colorClass="bg-gradient-to-br from-purple-100 to-purple-200 border-purple-400 text-purple-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Open Issues */}
|
||||||
|
<KPICard
|
||||||
|
title="Issues"
|
||||||
|
value={overview.open_issues}
|
||||||
|
subtitle="Open issues"
|
||||||
|
icon={AlertTriangle}
|
||||||
|
colorClass="bg-gradient-to-br from-orange-100 to-orange-200 border-orange-400 text-orange-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Average Rating */}
|
||||||
|
<KPICard
|
||||||
|
title="Avg Rating"
|
||||||
|
value={overview.avg_rating !== null ? `${overview.avg_rating.toFixed(1)}` : 'N/A'}
|
||||||
|
subtitle="Star rating"
|
||||||
|
icon={Star}
|
||||||
|
colorClass="bg-gradient-to-br from-yellow-100 to-yellow-200 border-yellow-400 text-yellow-900"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Positive Count */}
|
||||||
|
<KPICard
|
||||||
|
title="Positive"
|
||||||
|
value={overview.positive_count}
|
||||||
|
subtitle={`${positivePercent}% of mentions`}
|
||||||
|
icon={ThumbsUp}
|
||||||
|
colorClass="bg-gradient-to-br from-green-100 to-green-200 border-green-400 text-green-900"
|
||||||
|
onClick={() => toggleSentiment('positive')}
|
||||||
|
isActive={positiveActive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Negative Count */}
|
||||||
|
<KPICard
|
||||||
|
title="Negative"
|
||||||
|
value={overview.negative_count}
|
||||||
|
subtitle={`${negativePercent}% of mentions`}
|
||||||
|
icon={ThumbsDown}
|
||||||
|
colorClass="bg-gradient-to-br from-red-100 to-red-200 border-red-400 text-red-900"
|
||||||
|
onClick={() => toggleSentiment('negative')}
|
||||||
|
isActive={negativeActive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Neutral Count */}
|
||||||
|
<KPICard
|
||||||
|
title="Neutral"
|
||||||
|
value={overview.neutral_count}
|
||||||
|
subtitle="Neutral mentions"
|
||||||
|
icon={Target}
|
||||||
|
colorClass="bg-gradient-to-br from-gray-100 to-gray-200 border-gray-400 text-gray-900"
|
||||||
|
onClick={() => toggleSentiment('neutral')}
|
||||||
|
isActive={filters.sentiment.includes('neutral')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mixed Count */}
|
||||||
|
<KPICard
|
||||||
|
title="Mixed"
|
||||||
|
value={overview.mixed_count}
|
||||||
|
subtitle="Mixed mentions"
|
||||||
|
icon={TrendingUp}
|
||||||
|
colorClass="bg-gradient-to-br from-amber-100 to-amber-200 border-amber-400 text-amber-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
web/components/reviewiq/tables/IssueDetailModal.tsx
Normal file
259
web/components/reviewiq/tables/IssueDetailModal.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { X, AlertTriangle, Layers, Calendar, Target, User, Lightbulb, FileText } from 'lucide-react';
|
||||||
|
import type { IssueItem, SpanItem } from '../types';
|
||||||
|
import { DOMAIN_LABELS, INTENSITY_LABELS, VALENCE_LABELS, VALENCE_COLORS } from '../types';
|
||||||
|
import { useIssueSpans } from '@/hooks/useReviewIQAnalytics';
|
||||||
|
import { ReviewModal } from './ReviewModal';
|
||||||
|
|
||||||
|
interface IssueDetailModalProps {
|
||||||
|
issue: IssueItem;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal showing issue details and related spans.
|
||||||
|
*/
|
||||||
|
export function IssueDetailModal({ issue, onClose }: IssueDetailModalProps) {
|
||||||
|
const { data: spans, loading, error } = useIssueSpans(issue.issue_id);
|
||||||
|
const [selectedReview, setSelectedReview] = useState<{
|
||||||
|
reviewId: string;
|
||||||
|
spanId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl max-w-3xl w-full max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white border-b-2 border-gray-200 px-6 py-4 rounded-t-2xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">
|
||||||
|
{issue.subcode_name || issue.primary_subcode}
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm font-mono text-gray-500">{issue.primary_subcode}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-bold rounded border ${
|
||||||
|
issue.state === 'open'
|
||||||
|
? 'bg-red-100 text-red-800 border-red-300'
|
||||||
|
: issue.state === 'resolved'
|
||||||
|
? 'bg-green-100 text-green-800 border-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-800 border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{issue.state.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Issue Info Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-purple-50 rounded-lg p-4 border border-purple-200">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Target className="w-4 h-4 text-purple-600" />
|
||||||
|
<span className="text-xs font-semibold text-purple-700">URT Code</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-mono font-bold text-purple-900">
|
||||||
|
{issue.primary_subcode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Layers className="w-4 h-4 text-blue-600" />
|
||||||
|
<span className="text-xs font-semibold text-blue-700">Domain</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-blue-900">
|
||||||
|
{DOMAIN_LABELS[issue.domain] || issue.domain}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-orange-50 rounded-lg p-4 border border-orange-200">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||||
|
<span className="text-xs font-semibold text-orange-700">Priority</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-orange-900">
|
||||||
|
{(issue.priority_score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Calendar className="w-4 h-4 text-gray-600" />
|
||||||
|
<span className="text-xs font-semibold text-gray-700">Intensity</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-gray-900">
|
||||||
|
{issue.max_intensity
|
||||||
|
? INTENSITY_LABELS[issue.max_intensity] || issue.max_intensity
|
||||||
|
: 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entity */}
|
||||||
|
{issue.entity && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
|
<span className="text-sm font-semibold text-gray-700">Related Entity</span>
|
||||||
|
<p className="text-lg font-medium text-gray-900 mt-1">{issue.entity}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Solution & Owner Section */}
|
||||||
|
{(issue.solution || issue.default_owner) && (
|
||||||
|
<div className="bg-green-50 rounded-lg p-4 border border-green-200 space-y-3">
|
||||||
|
{issue.solution && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Lightbulb className="w-5 h-5 text-green-600" />
|
||||||
|
<span className="text-sm font-bold text-green-800">Recommended Solution</span>
|
||||||
|
{issue.solution_complexity && (
|
||||||
|
<span className={`ml-auto text-xs font-bold px-2 py-1 rounded ${
|
||||||
|
issue.solution_complexity === 'low'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: issue.solution_complexity === 'medium'
|
||||||
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{issue.solution_complexity.charAt(0).toUpperCase() + issue.solution_complexity.slice(1)} Complexity
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed">{issue.solution}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{issue.default_owner && (
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-green-200">
|
||||||
|
<User className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-sm text-green-800">
|
||||||
|
<span className="font-medium">Assign to:</span>{' '}
|
||||||
|
<span className="font-bold">{issue.default_owner}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related Spans */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="text-lg font-bold text-gray-900">
|
||||||
|
Related Spans ({issue.span_count})
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-red-500">
|
||||||
|
Failed to load spans: {error}
|
||||||
|
</div>
|
||||||
|
) : spans.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||||
|
No spans found for this issue
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||||
|
{spans.map((span: SpanItem) => (
|
||||||
|
<div
|
||||||
|
key={span.span_id}
|
||||||
|
className="bg-gray-50 rounded-lg p-4 border border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<p className="text-gray-800 flex-1">{span.span_text}</p>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{span.valence && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 text-xs font-bold rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${VALENCE_COLORS[span.valence]}20`,
|
||||||
|
color: VALENCE_COLORS[span.valence],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VALENCE_LABELS[span.valence] || span.valence}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{span.intensity && (
|
||||||
|
<span className="px-2 py-1 bg-gray-200 text-gray-700 text-xs font-bold rounded">
|
||||||
|
{INTENSITY_LABELS[span.intensity] || span.intensity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 mt-2">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
{span.urt_primary && (
|
||||||
|
<span className="font-mono">{span.urt_primary}</span>
|
||||||
|
)}
|
||||||
|
{span.review_time && (
|
||||||
|
<span>{new Date(span.review_time).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
{span.entity && <span>Entity: {span.entity}</span>}
|
||||||
|
</div>
|
||||||
|
{/* View Full Review Button */}
|
||||||
|
{span.source_review_id && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedReview({
|
||||||
|
reviewId: span.source_review_id!,
|
||||||
|
spanId: span.span_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
View Review
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="sticky bottom-0 bg-white border-t-2 border-gray-200 px-6 py-4 rounded-b-2xl">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-full py-3 bg-gray-900 text-white rounded-lg font-semibold hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review Modal for drill-down */}
|
||||||
|
{selectedReview && (
|
||||||
|
<ReviewModal
|
||||||
|
reviewId={selectedReview.reviewId}
|
||||||
|
highlightSpanId={selectedReview.spanId}
|
||||||
|
onClose={() => setSelectedReview(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
web/components/reviewiq/tables/IssuesTable.tsx
Normal file
286
web/components/reviewiq/tables/IssuesTable.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
SortingState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import type { IssueItem, PaginatedIssues } from '../types';
|
||||||
|
import { DOMAIN_LABELS, INTENSITY_LABELS } from '../types';
|
||||||
|
import { IssueDetailModal } from './IssueDetailModal';
|
||||||
|
|
||||||
|
interface IssuesTableProps {
|
||||||
|
issues: PaginatedIssues;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority badge color based on score
|
||||||
|
const getPriorityColor = (score: number): string => {
|
||||||
|
if (score >= 0.8) return 'bg-red-100 text-red-800 border-red-300';
|
||||||
|
if (score >= 0.5) return 'bg-orange-100 text-orange-800 border-orange-300';
|
||||||
|
if (score >= 0.3) return 'bg-yellow-100 text-yellow-800 border-yellow-300';
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
// State badge color
|
||||||
|
const getStateColor = (state: string): string => {
|
||||||
|
switch (state) {
|
||||||
|
case 'open':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-300';
|
||||||
|
case 'in_progress':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-300';
|
||||||
|
case 'resolved':
|
||||||
|
return 'bg-green-100 text-green-800 border-green-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues table with TanStack Table.
|
||||||
|
* Click rows to open drill-down modal.
|
||||||
|
*/
|
||||||
|
export function IssuesTable({ issues, onPageChange }: IssuesTableProps) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: 'priority_score', desc: true },
|
||||||
|
]);
|
||||||
|
const [selectedIssue, setSelectedIssue] = useState<IssueItem | null>(null);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<IssueItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'primary_subcode',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
Issue
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="w-4 h-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{row.original.subcode_name || row.original.primary_subcode}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 bg-purple-100 text-purple-800 text-xs font-mono font-bold rounded border border-purple-300 w-fit">
|
||||||
|
{row.original.primary_subcode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'domain',
|
||||||
|
header: 'Domain',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{DOMAIN_LABELS[row.original.domain] || row.original.domain}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'default_owner',
|
||||||
|
header: 'Owner',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs font-medium px-2 py-1 bg-blue-50 text-blue-700 rounded-full border border-blue-200">
|
||||||
|
{row.original.default_owner || 'Unassigned'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'state',
|
||||||
|
header: 'State',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-bold rounded border ${getStateColor(row.original.state)}`}
|
||||||
|
>
|
||||||
|
{row.original.state.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'priority_score',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
Priority
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="w-4 h-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-bold rounded border ${getPriorityColor(row.original.priority_score)}`}
|
||||||
|
>
|
||||||
|
{(row.original.priority_score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'span_count',
|
||||||
|
header: 'Spans',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{row.original.span_count}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'max_intensity',
|
||||||
|
header: 'Intensity',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{row.original.max_intensity
|
||||||
|
? INTENSITY_LABELS[row.original.max_intensity] || row.original.max_intensity
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIssue(row.original)}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: issues.items,
|
||||||
|
columns,
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: { pageSize: 10 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(issues.total / issues.page_size);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl p-6 shadow-md border-2 border-gray-300">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">Issues</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{issues.total} total issues - Click row for details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issues.items.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||||
|
No issues found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto border-2 border-gray-200 rounded-lg">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b-2 border-gray-200">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-4 py-3 text-left text-gray-900"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className="hover:bg-blue-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => setSelectedIssue(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-4 py-3">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-gray-700 font-medium">
|
||||||
|
Page {issues.page} of {totalPages} ({issues.total} issues)
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange?.(issues.page - 1)}
|
||||||
|
disabled={issues.page <= 1}
|
||||||
|
className="px-3 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange?.(issues.page + 1)}
|
||||||
|
disabled={issues.page >= totalPages}
|
||||||
|
className="px-3 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedIssue && (
|
||||||
|
<IssueDetailModal
|
||||||
|
issue={selectedIssue}
|
||||||
|
onClose={() => setSelectedIssue(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
web/components/reviewiq/tables/ReviewModal.tsx
Normal file
372
web/components/reviewiq/tables/ReviewModal.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Star,
|
||||||
|
ExternalLink,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
MapPin,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { FullReview, ReviewSpan } from '../types';
|
||||||
|
import {
|
||||||
|
VALENCE_COLORS,
|
||||||
|
VALENCE_LABELS,
|
||||||
|
INTENSITY_LABELS,
|
||||||
|
DOMAIN_LABELS,
|
||||||
|
DOMAIN_COLORS,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
interface ReviewModalProps {
|
||||||
|
reviewId: string | null;
|
||||||
|
source?: string;
|
||||||
|
highlightSpanId?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal showing a full review with classified spans highlighted.
|
||||||
|
* Enables drill-down from any aggregate metric to the raw source data.
|
||||||
|
*/
|
||||||
|
export function ReviewModal({
|
||||||
|
reviewId,
|
||||||
|
source = 'google',
|
||||||
|
highlightSpanId,
|
||||||
|
onClose,
|
||||||
|
}: ReviewModalProps) {
|
||||||
|
const [review, setReview] = useState<FullReview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch review when reviewId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reviewId) {
|
||||||
|
setReview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchReview = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/pipelines/reviewiq/reviews/${encodeURIComponent(reviewId)}?source=${encodeURIComponent(source)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch review: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setReview(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch review');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReview();
|
||||||
|
}, [reviewId, source]);
|
||||||
|
|
||||||
|
// Build highlighted text with spans marked using text-based matching
|
||||||
|
// (offsets in DB are unreliable, so we find spans by searching for their text)
|
||||||
|
const highlightedText = useMemo(() => {
|
||||||
|
if (!review?.review_text || !review.spans.length) {
|
||||||
|
return review?.review_text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = review.review_text;
|
||||||
|
|
||||||
|
// Find all span positions using text search
|
||||||
|
const spanPositions: Array<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
span: ReviewSpan;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const span of review.spans) {
|
||||||
|
if (!span.span_text) continue;
|
||||||
|
|
||||||
|
// Try exact match first
|
||||||
|
let idx = text.indexOf(span.span_text);
|
||||||
|
|
||||||
|
// If not found, try case-insensitive
|
||||||
|
if (idx === -1) {
|
||||||
|
idx = text.toLowerCase().indexOf(span.span_text.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx !== -1) {
|
||||||
|
spanPositions.push({
|
||||||
|
start: idx,
|
||||||
|
end: idx + span.span_text.length,
|
||||||
|
span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spanPositions.length === 0) {
|
||||||
|
// No matches found, return plain text
|
||||||
|
return review.review_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by position and remove overlaps (keep first occurrence)
|
||||||
|
spanPositions.sort((a, b) => a.start - b.start);
|
||||||
|
const nonOverlapping: typeof spanPositions = [];
|
||||||
|
let lastEnd = 0;
|
||||||
|
|
||||||
|
for (const pos of spanPositions) {
|
||||||
|
if (pos.start >= lastEnd) {
|
||||||
|
nonOverlapping.push(pos);
|
||||||
|
lastEnd = pos.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build segments
|
||||||
|
const segments: Array<{ text: string; span: ReviewSpan | null }> = [];
|
||||||
|
let currentPos = 0;
|
||||||
|
|
||||||
|
for (const pos of nonOverlapping) {
|
||||||
|
// Add text before this span
|
||||||
|
if (pos.start > currentPos) {
|
||||||
|
segments.push({ text: text.slice(currentPos, pos.start), span: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the span (use actual text from review, not span_text, to preserve case)
|
||||||
|
segments.push({ text: text.slice(pos.start, pos.end), span: pos.span });
|
||||||
|
currentPos = pos.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (currentPos < text.length) {
|
||||||
|
segments.push({ text: text.slice(currentPos), span: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}, [review]);
|
||||||
|
|
||||||
|
if (!reviewId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b-2 border-gray-200">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Full Review</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-red-600">
|
||||||
|
<AlertCircle className="w-12 h-12 mb-4" />
|
||||||
|
<p className="font-semibold">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : review ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Review Metadata */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Author */}
|
||||||
|
{review.author_name && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-700">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span className="font-semibold">{review.author_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Business */}
|
||||||
|
{review.business_name && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-600 text-sm">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
<span>{review.business_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Date */}
|
||||||
|
{review.review_time && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 text-sm">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span>{new Date(review.review_time).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
{review.rating !== null && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
i < review.rating!
|
||||||
|
? 'text-yellow-400 fill-yellow-400'
|
||||||
|
: 'text-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review Text with Highlighted Spans */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border-2 border-gray-200">
|
||||||
|
<p className="text-gray-800 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{Array.isArray(highlightedText) ? (
|
||||||
|
highlightedText.map((segment, idx) =>
|
||||||
|
segment.span ? (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`relative inline px-1 py-0.5 rounded ${
|
||||||
|
highlightSpanId === segment.span.span_id
|
||||||
|
? 'ring-2 ring-blue-500 ring-offset-1'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: segment.span.valence
|
||||||
|
? `${VALENCE_COLORS[segment.span.valence]}30`
|
||||||
|
: '#e5e7eb',
|
||||||
|
borderBottom: segment.span.urt_primary
|
||||||
|
? `3px solid ${DOMAIN_COLORS[segment.span.urt_primary[0]] || '#6b7280'}`
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
title={`${segment.span.urt_primary || 'Unknown'} | ${
|
||||||
|
VALENCE_LABELS[segment.span.valence || ''] || 'N/A'
|
||||||
|
} | ${INTENSITY_LABELS[segment.span.intensity || ''] || 'N/A'}`}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={idx}>{segment.text}</span>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
highlightedText
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: `${VALENCE_COLORS['V+']}30` }}
|
||||||
|
/>
|
||||||
|
<span>Positive</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: `${VALENCE_COLORS['V0']}30` }}
|
||||||
|
/>
|
||||||
|
<span>Neutral</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
style={{ backgroundColor: `${VALENCE_COLORS['V-']}30` }}
|
||||||
|
/>
|
||||||
|
<span>Negative</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">|</span>
|
||||||
|
<span className="text-gray-600">Underline = URT Domain</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Classified Spans List */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-3">
|
||||||
|
Classified Spans ({review.spans.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{review.spans.map((span) => (
|
||||||
|
<div
|
||||||
|
key={span.span_id}
|
||||||
|
className={`p-3 rounded-lg border-2 transition-all ${
|
||||||
|
highlightSpanId === span.span_id
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<p className="text-sm text-gray-700 flex-1 italic">
|
||||||
|
“{span.span_text}”
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* URT Code */}
|
||||||
|
{span.urt_primary && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 text-xs font-mono font-bold rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${DOMAIN_COLORS[span.urt_primary[0]] || '#6b7280'}20`,
|
||||||
|
color: DOMAIN_COLORS[span.urt_primary[0]] || '#6b7280',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{span.urt_primary}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Valence */}
|
||||||
|
{span.valence && (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 text-xs font-bold rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${VALENCE_COLORS[span.valence]}20`,
|
||||||
|
color: VALENCE_COLORS[span.valence],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VALENCE_LABELS[span.valence]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Intensity */}
|
||||||
|
{span.intensity && (
|
||||||
|
<span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-700 rounded">
|
||||||
|
{INTENSITY_LABELS[span.intensity]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Domain label */}
|
||||||
|
{span.urt_primary && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
{DOMAIN_LABELS[span.urt_primary[0]]} Domain
|
||||||
|
{span.entity && ` · Entity: ${span.entity}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* External Link */}
|
||||||
|
{review.review_url && (
|
||||||
|
<div className="pt-4 border-t-2 border-gray-200">
|
||||||
|
<a
|
||||||
|
href={review.review_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
View on Google Maps
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
334
web/components/reviewiq/tables/SpansTable.tsx
Normal file
334
web/components/reviewiq/tables/SpansTable.tsx
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, Fragment } from 'react';
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
SortingState,
|
||||||
|
Row,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight as ChevronRightIcon,
|
||||||
|
FileText,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { SpanItem, PaginatedSpans } from '../types';
|
||||||
|
import { VALENCE_LABELS, VALENCE_COLORS, INTENSITY_LABELS, DOMAIN_LABELS } from '../types';
|
||||||
|
import { ReviewModal } from './ReviewModal';
|
||||||
|
|
||||||
|
interface SpansTableProps {
|
||||||
|
spans: PaginatedSpans;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spans table with expandable rows and drill-down to full review.
|
||||||
|
*/
|
||||||
|
export function SpansTable({ spans, onPageChange }: SpansTableProps) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedReview, setSelectedReview] = useState<{
|
||||||
|
reviewId: string;
|
||||||
|
spanId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<SpanItem>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'expander',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => row.toggleExpanded()}
|
||||||
|
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
{row.getIsExpanded() ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'span_text',
|
||||||
|
header: 'Text',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<p className="text-sm text-gray-800 line-clamp-2">
|
||||||
|
{row.original.span_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'urt_primary',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
URT Code
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="w-4 h-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="px-2 py-1 bg-purple-100 text-purple-800 text-xs font-mono font-bold rounded border border-purple-300">
|
||||||
|
{row.original.urt_primary || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'valence',
|
||||||
|
header: 'Sentiment',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const valence = row.original.valence;
|
||||||
|
if (!valence) return <span className="text-gray-400">-</span>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="px-2 py-1 text-xs font-bold rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${VALENCE_COLORS[valence]}20`,
|
||||||
|
color: VALENCE_COLORS[valence],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VALENCE_LABELS[valence] || valence}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'intensity',
|
||||||
|
header: 'Intensity',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{row.original.intensity
|
||||||
|
? INTENSITY_LABELS[row.original.intensity] || row.original.intensity
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'review_time',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||||
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
||||||
|
>
|
||||||
|
Date
|
||||||
|
{column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
) : column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown className="w-4 h-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{row.original.review_time
|
||||||
|
? new Date(row.original.review_time).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: spans.items,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
expanded,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onExpandedChange: setExpanded as any,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getExpandedRowModel: getExpandedRowModel(),
|
||||||
|
getRowCanExpand: () => true,
|
||||||
|
initialState: {
|
||||||
|
pagination: { pageSize: 10 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(spans.total / spans.page_size);
|
||||||
|
|
||||||
|
// Render expanded row content
|
||||||
|
const renderExpandedRow = (row: Row<SpanItem>) => {
|
||||||
|
const span = row.original;
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="bg-gray-50 p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-semibold text-gray-700">Full Text</span>
|
||||||
|
<p className="text-sm text-gray-800 mt-1 whitespace-pre-wrap">
|
||||||
|
{span.span_text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700">Span ID</span>
|
||||||
|
<p className="text-gray-600 font-mono text-xs">{span.span_id}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700">Review ID</span>
|
||||||
|
<p className="text-gray-600 font-mono text-xs">
|
||||||
|
{span.source_review_id || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700">Entity</span>
|
||||||
|
<p className="text-gray-600">{span.entity || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-gray-700">Domain</span>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{span.urt_primary
|
||||||
|
? DOMAIN_LABELS[span.urt_primary[0]] || span.urt_primary[0]
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* View Full Review Button */}
|
||||||
|
{span.source_review_id && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedReview({
|
||||||
|
reviewId: span.source_review_id!,
|
||||||
|
spanId: span.span_id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
View Full Review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl p-6 shadow-md border-2 border-gray-300">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">Classified Spans</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{spans.total} total spans - Click row to expand
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spans.items.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||||
|
No spans found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto border-2 border-gray-200 rounded-lg">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b-2 border-gray-200">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<th
|
||||||
|
key={header.id}
|
||||||
|
className="px-4 py-3 text-left text-gray-900"
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<tr
|
||||||
|
className="hover:bg-blue-50 cursor-pointer transition-colors"
|
||||||
|
onClick={() => row.toggleExpanded()}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<td key={cell.id} className="px-4 py-3">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{row.getIsExpanded() && renderExpandedRow(row)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-gray-700 font-medium">
|
||||||
|
Page {spans.page} of {totalPages} ({spans.total} spans)
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange?.(spans.page - 1)}
|
||||||
|
disabled={spans.page <= 1}
|
||||||
|
className="px-3 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange?.(spans.page + 1)}
|
||||||
|
disabled={spans.page >= totalPages}
|
||||||
|
className="px-3 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Modal for drill-down */}
|
||||||
|
<ReviewModal
|
||||||
|
reviewId={selectedReview?.reviewId ?? null}
|
||||||
|
highlightSpanId={selectedReview?.spanId}
|
||||||
|
onClose={() => setSelectedReview(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,6 +99,8 @@ export interface WeaknessItem {
|
|||||||
solution_complexity: string | null;
|
solution_complexity: string | null;
|
||||||
projected_rating_impact: number | null;
|
projected_rating_impact: number | null;
|
||||||
owner: string | null;
|
owner: string | null;
|
||||||
|
// Optional: example quotes from reviews
|
||||||
|
example_spans?: OpportunitySpan[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RatingSimulator {
|
export interface RatingSimulator {
|
||||||
@@ -108,10 +110,31 @@ export interface RatingSimulator {
|
|||||||
potential_gain: number;
|
potential_gain: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpportunitySpan {
|
||||||
|
span_id: string;
|
||||||
|
span_text: string;
|
||||||
|
review_text: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
review_id: string | null;
|
||||||
|
review_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpportunityItem {
|
export interface OpportunityItem {
|
||||||
subcode: string;
|
subcode: string;
|
||||||
|
name: string; // Human-readable subcode name
|
||||||
x: number; // 0-1, frequency position within quadrant
|
x: number; // 0-1, frequency position within quadrant
|
||||||
y: number; // 0-1, effort position within quadrant
|
y: number; // 0-1, effort position within quadrant
|
||||||
|
// Detail data for hover/click
|
||||||
|
domain: string;
|
||||||
|
domain_name: string;
|
||||||
|
negative_pct: number;
|
||||||
|
span_count: number;
|
||||||
|
solution: string | null;
|
||||||
|
complexity: string;
|
||||||
|
rating_impact: number | null;
|
||||||
|
owner: string | null;
|
||||||
|
example: string | null;
|
||||||
|
spans: OpportunitySpan[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpportunityMatrix {
|
export interface OpportunityMatrix {
|
||||||
@@ -129,6 +152,58 @@ export interface Insights {
|
|||||||
executive_summary: string;
|
executive_summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== AI Synthesis (Stage 4 Output) ====================
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
why: string; // Root cause from reviews
|
||||||
|
what: string; // Specific action to take
|
||||||
|
who: string; // Department/role responsible
|
||||||
|
impact: string; // Expected outcome
|
||||||
|
evidence: string[]; // Example review quotes
|
||||||
|
estimated_rating_lift: number | null;
|
||||||
|
complexity: 'quick' | 'medium' | 'complex';
|
||||||
|
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
timeline: string; // e.g., "This week", "This month"
|
||||||
|
related_subcode: string; // URT subcode this addresses
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineAnnotation {
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
type: 'positive' | 'negative' | 'neutral' | 'event';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Synthesis {
|
||||||
|
// Narrative insights for each section
|
||||||
|
executive_narrative: string; // Main story for exec summary
|
||||||
|
sentiment_insight: string; // Why sentiment is this way
|
||||||
|
category_insight: string; // Pattern in categories
|
||||||
|
timeline_insight: string; // What's changing over time
|
||||||
|
|
||||||
|
// Highlights and focus areas
|
||||||
|
priority_domain: string | null; // Domain needing most attention
|
||||||
|
priority_issue: string | null; // Issue to fix first
|
||||||
|
|
||||||
|
// Actionable recommendations
|
||||||
|
action_plan: ActionItem[]; // Prioritized actions
|
||||||
|
issue_actions: Record<string, string>; // issue_id → recommended action
|
||||||
|
|
||||||
|
// Timeline context
|
||||||
|
timeline_annotations: TimelineAnnotation[];
|
||||||
|
|
||||||
|
// Marketing opportunities
|
||||||
|
marketing_angles: string[]; // Ways to promote strengths
|
||||||
|
|
||||||
|
// Competitor context (if available)
|
||||||
|
competitor_context: string | null;
|
||||||
|
|
||||||
|
// Generated at
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Issues (Enriched) ====================
|
// ==================== Issues (Enriched) ====================
|
||||||
|
|
||||||
export interface IssueItem {
|
export interface IssueItem {
|
||||||
@@ -232,6 +307,8 @@ export interface ReviewIQAnalyticsResponse {
|
|||||||
spans: PaginatedSpans;
|
spans: PaginatedSpans;
|
||||||
timeline: TimelinePoint[];
|
timeline: TimelinePoint[];
|
||||||
filters_applied: Record<string, unknown>;
|
filters_applied: Record<string, unknown>;
|
||||||
|
// NEW: AI-generated synthesis (optional, null if not generated yet)
|
||||||
|
synthesis: Synthesis | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Filter Types ====================
|
// ==================== Filter Types ====================
|
||||||
@@ -319,6 +396,16 @@ export const DOMAIN_LABELS: Record<string, string> = {
|
|||||||
R: 'Relationship',
|
R: 'Relationship',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DOMAIN_FRIENDLY: Record<string, { emoji: string; label: string; description: string }> = {
|
||||||
|
P: { emoji: '👥', label: 'Staff & Service', description: 'How staff treats customers' },
|
||||||
|
V: { emoji: '💰', label: 'Pricing & Value', description: 'Price, fees, and value for money' },
|
||||||
|
J: { emoji: '⏱️', label: 'Speed & Process', description: 'Wait times and procedures' },
|
||||||
|
O: { emoji: '🛍️', label: 'Product Quality', description: 'Quality of goods/services' },
|
||||||
|
A: { emoji: '📍', label: 'Availability', description: 'Hours, location, accessibility' },
|
||||||
|
E: { emoji: '🏢', label: 'Facilities', description: 'Cleanliness, comfort, ambiance' },
|
||||||
|
R: { emoji: '🤝', label: 'Trust & Ethics', description: 'Honesty, reliability, fairness' },
|
||||||
|
};
|
||||||
|
|
||||||
export const DOMAIN_OWNERS: Record<string, string> = {
|
export const DOMAIN_OWNERS: Record<string, string> = {
|
||||||
O: 'Operations / Product',
|
O: 'Operations / Product',
|
||||||
P: 'HR / Training',
|
P: 'HR / Training',
|
||||||
@@ -336,7 +423,7 @@ export const INTENSITY_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const COMPLEXITY_LABELS: Record<string, string> = {
|
export const COMPLEXITY_LABELS: Record<string, string> = {
|
||||||
simple: 'Quick Fix',
|
quick: 'Quick Fix',
|
||||||
medium: 'Moderate Effort',
|
medium: 'Moderate Effort',
|
||||||
complex: 'Strategic Initiative',
|
complex: 'Strategic Initiative',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user