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:
Alejandro Gutiérrez
2026-01-29 02:59:47 +00:00
parent 8f9dd136cd
commit c8ecb4b98f
21 changed files with 3959 additions and 90 deletions

View File

@@ -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", "": "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,
}))

View 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>
);
}

View 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>
);
}

View File

@@ -112,6 +112,7 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
avgRating={data.overview.avg_rating} avgRating={data.overview.avg_rating}
domainScores={data.domain_scores} domainScores={data.domain_scores}
onDomainClick={handleDomainClick} onDomainClick={handleDomainClick}
synthesis={data.synthesis}
/> />
{/* ═══════════════════════════════════════════════════════════════ {/* ═══════════════════════════════════════════════════════════════
@@ -119,8 +120,15 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
Side-by-side: How customers feel + What they talk about Side-by-side: How customers feel + What they talk about
═══════════════════════════════════════════════════════════════ */} ═══════════════════════════════════════════════════════════════ */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SentimentPie data={data.sentiment.distribution} /> <SentimentPie
<IntensityHeatmap data={data.urt.domains} /> data={data.sentiment.distribution}
insight={data.synthesis?.sentiment_insight}
/>
<IntensityHeatmap
data={data.urt.domains}
insight={data.synthesis?.category_insight}
highlightDomain={data.synthesis?.priority_domain}
/>
</div> </div>
{/* ═══════════════════════════════════════════════════════════════ {/* ═══════════════════════════════════════════════════════════════
@@ -133,7 +141,11 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
SECTION 4: TRENDS (Timeline) SECTION 4: TRENDS (Timeline)
How things change over time How things change over time
═══════════════════════════════════════════════════════════════ */} ═══════════════════════════════════════════════════════════════ */}
<TimelineChart data={data.timeline} /> <TimelineChart
data={data.timeline}
insight={data.synthesis?.timeline_insight}
annotations={data.synthesis?.timeline_annotations}
/>
{/* ═══════════════════════════════════════════════════════════════ {/* ═══════════════════════════════════════════════════════════════
SECTION 5: DEEP DIVE (Tables) SECTION 5: DEEP DIVE (Tables)

View File

@@ -7,6 +7,10 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
interface SentimentHeatmapProps { interface SentimentHeatmapProps {
data: URTDomainPoint[]; data: URTDomainPoint[];
// AI-generated insight (optional - shows when available)
insight?: string | null;
// Domain to highlight (optional - from AI priority)
highlightDomain?: string | null;
} }
// User-friendly domain config with emojis and descriptions // User-friendly domain config with emojis and descriptions
@@ -51,7 +55,7 @@ const getNegativeColor = (value: number, max: number): string => {
* User-friendly design with emojis and clear labels. * User-friendly design with emojis and clear labels.
* Click to filter by domain and sentiment. * Click to filter by domain and sentiment.
*/ */
export function IntensityHeatmap({ data }: SentimentHeatmapProps) { export function IntensityHeatmap({ data, insight, highlightDomain }: SentimentHeatmapProps) {
const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters(); const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters();
// Check if cross-filters are active // Check if cross-filters are active
@@ -136,6 +140,19 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
)} )}
</div> </div>
{/* AI Insight (when available) */}
{insight && (
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
<div className="flex items-start gap-2">
<span className="text-sm"></span>
<div>
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
<p className="text-sm text-gray-700">{insight}</p>
</div>
</div>
</div>
)}
{data.length === 0 ? ( {data.length === 0 ? (
<div className="flex items-center justify-center h-64 text-gray-500"> <div className="flex items-center justify-center h-64 text-gray-500">
No feedback data available No feedback data available
@@ -170,12 +187,15 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
const isDomainActive = filters.urtDomain === row.domain; const isDomainActive = filters.urtDomain === row.domain;
const isPositiveActive = isDomainActive && filters.sentiment.includes('positive'); const isPositiveActive = isDomainActive && filters.sentiment.includes('positive');
const isNegativeActive = isDomainActive && filters.sentiment.includes('negative'); const isNegativeActive = isDomainActive && filters.sentiment.includes('negative');
const isHighlighted = highlightDomain === row.domain;
return ( return (
<tr <tr
key={row.domain} key={row.domain}
className={`border-t border-gray-100 transition-colors ${ className={`border-t border-gray-100 transition-colors ${
isDomainActive ? 'bg-blue-50' : 'hover:bg-gray-50' isDomainActive ? 'bg-blue-50' :
isHighlighted ? 'bg-purple-50 ring-1 ring-purple-300' :
'hover:bg-gray-50'
}`} }`}
> >
{/* Domain Label */} {/* Domain Label */}

View File

@@ -8,6 +8,8 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
interface SentimentPieProps { interface SentimentPieProps {
data: SentimentDataPoint[]; data: SentimentDataPoint[];
// AI-generated insight (optional - shows when available)
insight?: string | null;
} }
// User-friendly sentiment config // User-friendly sentiment config
@@ -74,7 +76,7 @@ const SENTIMENT_ORDER = ['V+', 'V-', 'V0', 'V±'];
* User-friendly design with emojis and clear numbers. * User-friendly design with emojis and clear numbers.
* Click to filter by sentiment. * Click to filter by sentiment.
*/ */
export function SentimentPie({ data }: SentimentPieProps) { export function SentimentPie({ data, insight }: SentimentPieProps) {
const { filters, toggleSentiment } = useReviewIQFilters(); const { filters, toggleSentiment } = useReviewIQFilters();
// Process data // Process data
@@ -185,6 +187,19 @@ export function SentimentPie({ data }: SentimentPieProps) {
</div> </div>
</div> </div>
{/* AI Insight (when available) */}
{insight && (
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
<div className="flex items-start gap-2">
<span className="text-sm"></span>
<div>
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
<p className="text-sm text-gray-700">{insight}</p>
</div>
</div>
</div>
)}
{processedData.cards.length === 0 ? ( {processedData.cards.length === 0 ? (
<div className="flex items-center justify-center h-64 text-gray-500"> <div className="flex items-center justify-center h-64 text-gray-500">
No sentiment data available No sentiment data available

View File

@@ -15,12 +15,16 @@ import {
ReferenceLine, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react'; import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
import type { TimelinePoint, TimeRange } from '../types'; import type { TimelinePoint, TimeRange, TimelineAnnotation } from '../types';
import { DOMAIN_LABELS } from '../types'; import { DOMAIN_LABELS } from '../types';
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
interface TimelineChartProps { interface TimelineChartProps {
data: TimelinePoint[]; data: TimelinePoint[];
// AI-generated insight (optional - shows when available)
insight?: string | null;
// Timeline annotations from AI (optional - marks key events)
annotations?: TimelineAnnotation[] | null;
} }
type ViewMode = 'sentiment' | 'volume' | 'rating'; type ViewMode = 'sentiment' | 'volume' | 'rating';
@@ -45,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string
* User-friendly design with view toggles and interactive brush. * User-friendly design with view toggles and interactive brush.
* Responds to domain/sentiment filters. * Responds to domain/sentiment filters.
*/ */
export function TimelineChart({ data }: TimelineChartProps) { export function TimelineChart({ data, insight, annotations }: TimelineChartProps) {
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters(); const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
const [viewMode, setViewMode] = useState<ViewMode>('sentiment'); const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
const [localBrushRange, setLocalBrushRange] = useState<{ const [localBrushRange, setLocalBrushRange] = useState<{
@@ -264,6 +268,44 @@ export function TimelineChart({ data }: TimelineChartProps) {
)} )}
</div> </div>
{/* AI Insight (when available) */}
{insight && (
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
<div className="flex items-start gap-2">
<span className="text-sm"></span>
<div>
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
<p className="text-sm text-gray-700">{insight}</p>
</div>
</div>
</div>
)}
{/* Key Events (when annotations available) */}
{annotations && annotations.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{annotations.slice(0, 3).map((annotation, idx) => (
<div
key={idx}
className={`px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1 ${
annotation.type === 'positive' ? 'bg-green-100 text-green-700' :
annotation.type === 'negative' ? 'bg-red-100 text-red-700' :
annotation.type === 'event' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
}`}
title={annotation.description}
>
<span>{
annotation.type === 'positive' ? '📈' :
annotation.type === 'negative' ? '📉' :
annotation.type === 'event' ? '📍' : '•'
}</span>
<span>{annotation.label}</span>
</div>
))}
</div>
)}
{sortedData.length === 0 ? ( {sortedData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-80 text-gray-500"> <div className="flex flex-col items-center justify-center h-80 text-gray-500">
<Calendar className="w-12 h-12 text-gray-300 mb-2" /> <Calendar className="w-12 h-12 text-gray-300 mb-2" />

View 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';

View File

@@ -16,7 +16,7 @@ import {
Award, Award,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types'; import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types';
import { getSubcodeDefinition } from '@/lib/taxonomy/data'; import { getSubcodeDefinition } from '@/lib/taxonomy/data';
interface ExecutiveSummaryProps { interface ExecutiveSummaryProps {
@@ -25,6 +25,8 @@ interface ExecutiveSummaryProps {
domainScores?: DomainScore[]; domainScores?: DomainScore[];
onDriverClick?: (subcode: string) => void; onDriverClick?: (subcode: string) => void;
onDomainClick?: (domain: URTDomain) => void; onDomainClick?: (domain: URTDomain) => void;
// AI-generated narrative (optional - enhances when available)
synthesis?: Synthesis | null;
} }
// User-friendly domain config // User-friendly domain config
@@ -199,10 +201,14 @@ export function ExecutiveSummary({
domainScores, domainScores,
onDriverClick, onDriverClick,
onDomainClick, onDomainClick,
synthesis,
}: ExecutiveSummaryProps) { }: ExecutiveSummaryProps) {
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights; const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
const [showFullSummary, setShowFullSummary] = useState(false); const [showFullSummary, setShowFullSummary] = useState(false);
// Use AI narrative if available, otherwise fall back to generated summary
const narrativeText = synthesis?.executive_narrative || executive_summary;
const topStrength = strengths[0]; const topStrength = strengths[0];
const topWeakness = weaknesses[0]; const topWeakness = weaknesses[0];
const ratingDisplay = getRatingDisplay(avgRating); const ratingDisplay = getRatingDisplay(avgRating);
@@ -286,16 +292,23 @@ export function ExecutiveSummary({
</div> </div>
{/* AI Summary */} {/* AI Summary */}
{executive_summary && ( {narrativeText && (
<div className="px-6 pb-4"> <div className="px-6 pb-4">
<div className="p-4 bg-white/70 rounded-xl border border-blue-100"> <div className={`p-4 rounded-xl border ${
synthesis?.executive_narrative
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
: 'bg-white/70 border-blue-100'
}`}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-lg">💡</span> <span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
<div> <div className="flex-1">
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-2'}`}> {synthesis?.executive_narrative && (
{executive_summary} <div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
)}
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
{narrativeText}
</p> </p>
{executive_summary.length > 150 && ( {narrativeText.length > 200 && (
<button <button
onClick={() => setShowFullSummary(!showFullSummary)} onClick={() => setShowFullSummary(!showFullSummary)}
className="text-blue-600 text-sm font-medium mt-1 hover:underline" className="text-blue-600 text-sm font-medium mt-1 hover:underline"

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,4 @@
export { ExecutiveSummary } from './ExecutiveSummary';
export { StrengthsWeaknesses } from './StrengthsWeaknesses';
export { OpportunityMatrix } from './OpportunityMatrix';
export { RatingSimulator } from './RatingSimulator';

View 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">&lt;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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">
&ldquo;{span.span_text}&rdquo;
</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>
);
}

View 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>
);
}

View File

@@ -99,6 +99,8 @@ export interface WeaknessItem {
solution_complexity: string | null; solution_complexity: string | null;
projected_rating_impact: number | null; projected_rating_impact: number | null;
owner: string | null; owner: string | null;
// Optional: example quotes from reviews
example_spans?: OpportunitySpan[];
} }
export interface RatingSimulator { export interface RatingSimulator {
@@ -108,10 +110,31 @@ export interface RatingSimulator {
potential_gain: number; potential_gain: number;
} }
export interface OpportunitySpan {
span_id: string;
span_text: string;
review_text: string | null;
rating: number | null;
review_id: string | null;
review_date: string | null;
}
export interface OpportunityItem { export interface OpportunityItem {
subcode: string; subcode: string;
name: string; // Human-readable subcode name
x: number; // 0-1, frequency position within quadrant x: number; // 0-1, frequency position within quadrant
y: number; // 0-1, effort position within quadrant y: number; // 0-1, effort position within quadrant
// Detail data for hover/click
domain: string;
domain_name: string;
negative_pct: number;
span_count: number;
solution: string | null;
complexity: string;
rating_impact: number | null;
owner: string | null;
example: string | null;
spans: OpportunitySpan[];
} }
export interface OpportunityMatrix { export interface OpportunityMatrix {
@@ -129,6 +152,58 @@ export interface Insights {
executive_summary: string; executive_summary: string;
} }
// ==================== AI Synthesis (Stage 4 Output) ====================
export interface ActionItem {
id: string;
title: string;
why: string; // Root cause from reviews
what: string; // Specific action to take
who: string; // Department/role responsible
impact: string; // Expected outcome
evidence: string[]; // Example review quotes
estimated_rating_lift: number | null;
complexity: 'quick' | 'medium' | 'complex';
priority: 'critical' | 'high' | 'medium' | 'low';
timeline: string; // e.g., "This week", "This month"
related_subcode: string; // URT subcode this addresses
}
export interface TimelineAnnotation {
date: string;
label: string;
description: string;
type: 'positive' | 'negative' | 'neutral' | 'event';
}
export interface Synthesis {
// Narrative insights for each section
executive_narrative: string; // Main story for exec summary
sentiment_insight: string; // Why sentiment is this way
category_insight: string; // Pattern in categories
timeline_insight: string; // What's changing over time
// Highlights and focus areas
priority_domain: string | null; // Domain needing most attention
priority_issue: string | null; // Issue to fix first
// Actionable recommendations
action_plan: ActionItem[]; // Prioritized actions
issue_actions: Record<string, string>; // issue_id → recommended action
// Timeline context
timeline_annotations: TimelineAnnotation[];
// Marketing opportunities
marketing_angles: string[]; // Ways to promote strengths
// Competitor context (if available)
competitor_context: string | null;
// Generated at
generated_at: string;
}
// ==================== Issues (Enriched) ==================== // ==================== Issues (Enriched) ====================
export interface IssueItem { export interface IssueItem {
@@ -232,6 +307,8 @@ export interface ReviewIQAnalyticsResponse {
spans: PaginatedSpans; spans: PaginatedSpans;
timeline: TimelinePoint[]; timeline: TimelinePoint[];
filters_applied: Record<string, unknown>; filters_applied: Record<string, unknown>;
// NEW: AI-generated synthesis (optional, null if not generated yet)
synthesis: Synthesis | null;
} }
// ==================== Filter Types ==================== // ==================== Filter Types ====================
@@ -319,6 +396,16 @@ export const DOMAIN_LABELS: Record<string, string> = {
R: 'Relationship', R: 'Relationship',
}; };
export const DOMAIN_FRIENDLY: Record<string, { emoji: string; label: string; description: string }> = {
P: { emoji: '👥', label: 'Staff & Service', description: 'How staff treats customers' },
V: { emoji: '💰', label: 'Pricing & Value', description: 'Price, fees, and value for money' },
J: { emoji: '⏱️', label: 'Speed & Process', description: 'Wait times and procedures' },
O: { emoji: '🛍️', label: 'Product Quality', description: 'Quality of goods/services' },
A: { emoji: '📍', label: 'Availability', description: 'Hours, location, accessibility' },
E: { emoji: '🏢', label: 'Facilities', description: 'Cleanliness, comfort, ambiance' },
R: { emoji: '🤝', label: 'Trust & Ethics', description: 'Honesty, reliability, fairness' },
};
export const DOMAIN_OWNERS: Record<string, string> = { export const DOMAIN_OWNERS: Record<string, string> = {
O: 'Operations / Product', O: 'Operations / Product',
P: 'HR / Training', P: 'HR / Training',
@@ -336,7 +423,7 @@ export const INTENSITY_LABELS: Record<string, string> = {
}; };
export const COMPLEXITY_LABELS: Record<string, string> = { export const COMPLEXITY_LABELS: Record<string, string> = {
simple: 'Quick Fix', quick: 'Quick Fix',
medium: 'Moderate Effort', medium: 'Moderate Effort',
complex: 'Strategic Initiative', complex: 'Strategic Initiative',
}; };