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}
|
||||
domainScores={data.domain_scores}
|
||||
onDomainClick={handleDomainClick}
|
||||
synthesis={data.synthesis}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════
|
||||
@@ -119,8 +120,15 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
||||
Side-by-side: How customers feel + What they talk about
|
||||
═══════════════════════════════════════════════════════════════ */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<SentimentPie data={data.sentiment.distribution} />
|
||||
<IntensityHeatmap data={data.urt.domains} />
|
||||
<SentimentPie
|
||||
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>
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════════════
|
||||
@@ -133,7 +141,11 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
||||
SECTION 4: TRENDS (Timeline)
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,10 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||
|
||||
interface SentimentHeatmapProps {
|
||||
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
|
||||
@@ -51,7 +55,7 @@ const getNegativeColor = (value: number, max: number): string => {
|
||||
* User-friendly design with emojis and clear labels.
|
||||
* Click to filter by domain and sentiment.
|
||||
*/
|
||||
export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
|
||||
export function IntensityHeatmap({ data, insight, highlightDomain }: SentimentHeatmapProps) {
|
||||
const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters();
|
||||
|
||||
// Check if cross-filters are active
|
||||
@@ -136,6 +140,19 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
|
||||
)}
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
No feedback data available
|
||||
@@ -170,12 +187,15 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
|
||||
const isDomainActive = filters.urtDomain === row.domain;
|
||||
const isPositiveActive = isDomainActive && filters.sentiment.includes('positive');
|
||||
const isNegativeActive = isDomainActive && filters.sentiment.includes('negative');
|
||||
const isHighlighted = highlightDomain === row.domain;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={row.domain}
|
||||
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 */}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||
|
||||
interface SentimentPieProps {
|
||||
data: SentimentDataPoint[];
|
||||
// AI-generated insight (optional - shows when available)
|
||||
insight?: string | null;
|
||||
}
|
||||
|
||||
// User-friendly sentiment config
|
||||
@@ -74,7 +76,7 @@ const SENTIMENT_ORDER = ['V+', 'V-', 'V0', 'V±'];
|
||||
* User-friendly design with emojis and clear numbers.
|
||||
* Click to filter by sentiment.
|
||||
*/
|
||||
export function SentimentPie({ data }: SentimentPieProps) {
|
||||
export function SentimentPie({ data, insight }: SentimentPieProps) {
|
||||
const { filters, toggleSentiment } = useReviewIQFilters();
|
||||
|
||||
// Process data
|
||||
@@ -185,6 +187,19 @@ export function SentimentPie({ data }: SentimentPieProps) {
|
||||
</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 ? (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
No sentiment data available
|
||||
|
||||
@@ -15,12 +15,16 @@ import {
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
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 { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||
|
||||
interface TimelineChartProps {
|
||||
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';
|
||||
@@ -45,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string
|
||||
* User-friendly design with view toggles and interactive brush.
|
||||
* Responds to domain/sentiment filters.
|
||||
*/
|
||||
export function TimelineChart({ data }: TimelineChartProps) {
|
||||
export function TimelineChart({ data, insight, annotations }: TimelineChartProps) {
|
||||
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
|
||||
const [localBrushRange, setLocalBrushRange] = useState<{
|
||||
@@ -264,6 +268,44 @@ export function TimelineChart({ data }: TimelineChartProps) {
|
||||
)}
|
||||
</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 ? (
|
||||
<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" />
|
||||
|
||||
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,
|
||||
} from 'lucide-react';
|
||||
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';
|
||||
|
||||
interface ExecutiveSummaryProps {
|
||||
@@ -25,6 +25,8 @@ interface ExecutiveSummaryProps {
|
||||
domainScores?: DomainScore[];
|
||||
onDriverClick?: (subcode: string) => void;
|
||||
onDomainClick?: (domain: URTDomain) => void;
|
||||
// AI-generated narrative (optional - enhances when available)
|
||||
synthesis?: Synthesis | null;
|
||||
}
|
||||
|
||||
// User-friendly domain config
|
||||
@@ -199,10 +201,14 @@ export function ExecutiveSummary({
|
||||
domainScores,
|
||||
onDriverClick,
|
||||
onDomainClick,
|
||||
synthesis,
|
||||
}: ExecutiveSummaryProps) {
|
||||
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
|
||||
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 topWeakness = weaknesses[0];
|
||||
const ratingDisplay = getRatingDisplay(avgRating);
|
||||
@@ -286,16 +292,23 @@ export function ExecutiveSummary({
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{executive_summary && (
|
||||
{narrativeText && (
|
||||
<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">
|
||||
<span className="text-lg">💡</span>
|
||||
<div>
|
||||
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-2'}`}>
|
||||
{executive_summary}
|
||||
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
|
||||
<div className="flex-1">
|
||||
{synthesis?.executive_narrative && (
|
||||
<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>
|
||||
{executive_summary.length > 150 && (
|
||||
{narrativeText.length > 200 && (
|
||||
<button
|
||||
onClick={() => setShowFullSummary(!showFullSummary)}
|
||||
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;
|
||||
projected_rating_impact: number | null;
|
||||
owner: string | null;
|
||||
// Optional: example quotes from reviews
|
||||
example_spans?: OpportunitySpan[];
|
||||
}
|
||||
|
||||
export interface RatingSimulator {
|
||||
@@ -108,10 +110,31 @@ export interface RatingSimulator {
|
||||
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 {
|
||||
subcode: string;
|
||||
name: string; // Human-readable subcode name
|
||||
x: number; // 0-1, frequency 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 {
|
||||
@@ -129,6 +152,58 @@ export interface Insights {
|
||||
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) ====================
|
||||
|
||||
export interface IssueItem {
|
||||
@@ -232,6 +307,8 @@ export interface ReviewIQAnalyticsResponse {
|
||||
spans: PaginatedSpans;
|
||||
timeline: TimelinePoint[];
|
||||
filters_applied: Record<string, unknown>;
|
||||
// NEW: AI-generated synthesis (optional, null if not generated yet)
|
||||
synthesis: Synthesis | null;
|
||||
}
|
||||
|
||||
// ==================== Filter Types ====================
|
||||
@@ -319,6 +396,16 @@ export const DOMAIN_LABELS: Record<string, string> = {
|
||||
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> = {
|
||||
O: 'Operations / Product',
|
||||
P: 'HR / Training',
|
||||
@@ -336,7 +423,7 @@ export const INTENSITY_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const COMPLEXITY_LABELS: Record<string, string> = {
|
||||
simple: 'Quick Fix',
|
||||
quick: 'Quick Fix',
|
||||
medium: 'Moderate Effort',
|
||||
complex: 'Strategic Initiative',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user