From c8ecb4b98f09e0672d6f57691d9fbe4e59e5cd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Thu, 29 Jan 2026 02:59:47 +0000 Subject: [PATCH] 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 --- .../stages/stage4_synthesize.py | 477 ++++++++ web/components/reviewiq/DashboardSkeleton.tsx | 74 ++ web/components/reviewiq/FilterBar.tsx | 128 +++ web/components/reviewiq/ReviewIQDashboard.tsx | 18 +- .../reviewiq/charts/IntensityHeatmap.tsx | 24 +- .../reviewiq/charts/SentimentPie.tsx | 17 +- .../reviewiq/charts/TimelineChart.tsx | 46 +- web/components/reviewiq/index.ts | 36 + .../reviewiq/insights/ExecutiveSummary.tsx | 29 +- .../reviewiq/insights/OpportunityMatrix.tsx | 1023 +++++++++++++++-- .../reviewiq/insights/RatingSimulator.tsx | 204 ++++ .../reviewiq/insights/StrengthsWeaknesses.tsx | 185 +++ web/components/reviewiq/insights/index.ts | 4 + web/components/reviewiq/kpi/DomainScores.tsx | 261 +++++ web/components/reviewiq/kpi/KPICard.tsx | 63 + web/components/reviewiq/kpi/KPISection.tsx | 120 ++ .../reviewiq/tables/IssueDetailModal.tsx | 259 +++++ .../reviewiq/tables/IssuesTable.tsx | 286 +++++ .../reviewiq/tables/ReviewModal.tsx | 372 ++++++ web/components/reviewiq/tables/SpansTable.tsx | 334 ++++++ web/components/reviewiq/types.ts | 89 +- 21 files changed, 3959 insertions(+), 90 deletions(-) create mode 100644 packages/reviewiq-pipeline/src/reviewiq_pipeline/stages/stage4_synthesize.py create mode 100644 web/components/reviewiq/DashboardSkeleton.tsx create mode 100644 web/components/reviewiq/FilterBar.tsx create mode 100644 web/components/reviewiq/index.ts create mode 100644 web/components/reviewiq/insights/RatingSimulator.tsx create mode 100644 web/components/reviewiq/insights/StrengthsWeaknesses.tsx create mode 100644 web/components/reviewiq/insights/index.ts create mode 100644 web/components/reviewiq/kpi/DomainScores.tsx create mode 100644 web/components/reviewiq/kpi/KPICard.tsx create mode 100644 web/components/reviewiq/kpi/KPISection.tsx create mode 100644 web/components/reviewiq/tables/IssueDetailModal.tsx create mode 100644 web/components/reviewiq/tables/IssuesTable.tsx create mode 100644 web/components/reviewiq/tables/ReviewModal.tsx create mode 100644 web/components/reviewiq/tables/SpansTable.tsx diff --git a/packages/reviewiq-pipeline/src/reviewiq_pipeline/stages/stage4_synthesize.py b/packages/reviewiq-pipeline/src/reviewiq_pipeline/stages/stage4_synthesize.py new file mode 100644 index 0000000..6573ce8 --- /dev/null +++ b/packages/reviewiq-pipeline/src/reviewiq_pipeline/stages/stage4_synthesize.py @@ -0,0 +1,477 @@ +""" +Stage 4: Synthesize - Generate AI narratives and action plans. + +This stage runs after classification and routing to produce: +- Executive narrative (business-specific story) +- Section insights (sentiment, category, timeline) +- Action plan with prioritized recommendations +- Timeline annotations for key events +- Marketing angles from strengths +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import asyncpg + +from reviewiq_pipeline.services.llm_client import LLMClientBase + +logger = logging.getLogger(__name__) + + +@dataclass +class ActionItem: + """A specific action recommendation.""" + id: str + title: str + why: str + what: str + who: str + impact: str + evidence: list[str] + estimated_rating_lift: float | None + complexity: str # 'quick' | 'medium' | 'complex' + priority: str # 'critical' | 'high' | 'medium' | 'low' + timeline: str + related_subcode: str + + +@dataclass +class TimelineAnnotation: + """An annotation for a key event on the timeline.""" + date: str + label: str + description: str + type: str # 'positive' | 'negative' | 'neutral' | 'event' + + +@dataclass +class Synthesis: + """Complete synthesis output from Stage 4.""" + executive_narrative: str + sentiment_insight: str + category_insight: str + timeline_insight: str + priority_domain: str | None + priority_issue: str | None + action_plan: list[ActionItem] + issue_actions: dict[str, str] + timeline_annotations: list[TimelineAnnotation] + marketing_angles: list[str] + competitor_context: str | None + generated_at: str + + +SYNTHESIS_SYSTEM_PROMPT = """You are an expert business analyst specializing in customer experience and review analysis. + +Your task is to analyze classified review data and generate actionable business insights. + +You will receive: +1. Summary statistics (total reviews, rating, sentiment distribution) +2. Top issues by category with example quotes +3. Top strengths with example quotes +4. Domain breakdown (what customers talk about most) + +Generate a JSON response with these fields: + +{ + "executive_narrative": "2-3 paragraph story explaining the business situation, key problems, and path forward. Be specific with numbers and examples.", + + "sentiment_insight": "1-2 sentences explaining WHY sentiment is distributed this way. Connect to specific issues.", + + "category_insight": "1-2 sentences about the pattern in categories. Which domain needs most attention and why?", + + "timeline_insight": "1-2 sentences about trends if data shows changes over time.", + + "priority_domain": "Single letter code (P/V/J/O/A/E/R) for the domain needing most attention, or null", + + "priority_issue": "The subcode (e.g., 'V1.03') that should be fixed first, or null", + + "action_plan": [ + { + "id": "action_1", + "title": "Clear action title", + "why": "Root cause from the reviews", + "what": "Specific steps to take", + "who": "Department or role responsible", + "impact": "Expected outcome", + "evidence": ["Quote 1", "Quote 2"], + "estimated_rating_lift": 0.3, + "complexity": "quick|medium|complex", + "priority": "critical|high|medium|low", + "timeline": "This week|This month|This quarter", + "related_subcode": "V1.03" + } + ], + + "timeline_annotations": [ + { + "date": "2024-01-15", + "label": "Short label", + "description": "What happened", + "type": "positive|negative|neutral|event" + } + ], + + "marketing_angles": [ + "Way to promote strength 1", + "Way to promote strength 2" + ], + + "competitor_context": "How this compares to industry/competitors, or null if unknown" +} + +Be specific, actionable, and business-focused. Use actual numbers and quotes from the data. +Prioritize actions by impact and feasibility. +""" + + +class SynthesisStage: + """ + Stage 4: Generate AI synthesis from classified review data. + + This stage: + 1. Aggregates classification results + 2. Identifies patterns and priorities + 3. Generates narrative insights via LLM + 4. Produces actionable recommendations + """ + + def __init__(self, pool: asyncpg.Pool, llm_client: LLMClientBase): + self.pool = pool + self.llm_client = llm_client + + async def run(self, job_id: str, execution_id: str) -> Synthesis: + """ + Generate synthesis for a completed pipeline execution. + + Args: + job_id: The scraping job ID + execution_id: The pipeline execution ID + + Returns: + Synthesis object with all generated insights + """ + logger.info(f"Stage 4: Generating synthesis for job {job_id}") + + # Gather all the data we need + context = await self._gather_context(job_id) + + # Generate synthesis via LLM + synthesis = await self._generate_synthesis(context) + + # Store synthesis in database + await self._store_synthesis(execution_id, synthesis) + + logger.info(f"Stage 4: Synthesis complete - {len(synthesis.action_plan)} actions generated") + return synthesis + + async def _gather_context(self, job_id: str) -> dict[str, Any]: + """Gather all context needed for synthesis.""" + + # Get overview stats + overview = await self.pool.fetchrow(""" + SELECT + COUNT(DISTINCT r.review_id) as total_reviews, + AVG(r.rating) as avg_rating, + COUNT(s.span_id) as total_spans + FROM reviews r + LEFT JOIN pipeline.spans s ON s.source_review_id = r.review_id + WHERE r.job_id = $1 + """, job_id) + + # Get sentiment distribution + sentiment = await self.pool.fetch(""" + SELECT + valence, + COUNT(*) as count, + COUNT(DISTINCT source_review_id) as review_count + FROM pipeline.spans + WHERE job_id = $1 AND valence IS NOT NULL + GROUP BY valence + ORDER BY count DESC + """, job_id) + + # Get top issues (weaknesses) + top_issues = await self.pool.fetch(""" + SELECT + s.urt_primary as subcode, + sc.name as subcode_name, + sc.definition, + d.code as domain, + d.name as domain_name, + COUNT(*) as span_count, + COUNT(*) FILTER (WHERE s.valence = 'V-') as negative_count, + ARRAY_AGG(s.span_text ORDER BY s.intensity DESC) FILTER (WHERE s.valence = 'V-') as example_quotes + FROM pipeline.spans s + JOIN pipeline.urt_subcodes sc ON sc.code = s.urt_primary + JOIN pipeline.urt_domains d ON d.code = SUBSTRING(s.urt_primary, 1, 1) + WHERE s.job_id = $1 AND s.valence = 'V-' + GROUP BY s.urt_primary, sc.name, sc.definition, d.code, d.name + ORDER BY negative_count DESC + LIMIT 10 + """, job_id) + + # Get top strengths + top_strengths = await self.pool.fetch(""" + SELECT + s.urt_primary as subcode, + sc.name as subcode_name, + sc.definition, + d.code as domain, + d.name as domain_name, + COUNT(*) as span_count, + COUNT(*) FILTER (WHERE s.valence = 'V+') as positive_count, + ARRAY_AGG(s.span_text ORDER BY s.intensity DESC) FILTER (WHERE s.valence = 'V+') as example_quotes + FROM pipeline.spans s + JOIN pipeline.urt_subcodes sc ON sc.code = s.urt_primary + JOIN pipeline.urt_domains d ON d.code = SUBSTRING(s.urt_primary, 1, 1) + WHERE s.job_id = $1 AND s.valence = 'V+' + GROUP BY s.urt_primary, sc.name, sc.definition, d.code, d.name + ORDER BY positive_count DESC + LIMIT 5 + """, job_id) + + # Get domain distribution + domains = await self.pool.fetch(""" + SELECT + SUBSTRING(urt_primary, 1, 1) as domain, + d.name as domain_name, + COUNT(*) as total_count, + COUNT(*) FILTER (WHERE valence = 'V+') as positive_count, + COUNT(*) FILTER (WHERE valence = 'V-') as negative_count + FROM pipeline.spans s + JOIN pipeline.urt_domains d ON d.code = SUBSTRING(s.urt_primary, 1, 1) + WHERE s.job_id = $1 + GROUP BY SUBSTRING(urt_primary, 1, 1), d.name + ORDER BY total_count DESC + """, job_id) + + # Get business name if available + business = await self.pool.fetchrow(""" + SELECT DISTINCT business_name + FROM reviews + WHERE job_id = $1 AND business_name IS NOT NULL + LIMIT 1 + """, job_id) + + return { + "business_name": business["business_name"] if business else "This business", + "overview": dict(overview) if overview else {}, + "sentiment": [dict(r) for r in sentiment], + "top_issues": [dict(r) for r in top_issues], + "top_strengths": [dict(r) for r in top_strengths], + "domains": [dict(r) for r in domains], + } + + async def _generate_synthesis(self, context: dict[str, Any]) -> Synthesis: + """Generate synthesis using LLM.""" + + # Build the user prompt with context + user_prompt = f"""Analyze this review data for {context['business_name']}: + +## Overview +- Total Reviews: {context['overview'].get('total_reviews', 0)} +- Average Rating: {context['overview'].get('avg_rating', 'N/A')} +- Total Insights Extracted: {context['overview'].get('total_spans', 0)} + +## Sentiment Distribution +{self._format_sentiment(context['sentiment'])} + +## Top Issues (Problems) +{self._format_issues(context['top_issues'])} + +## Top Strengths +{self._format_strengths(context['top_strengths'])} + +## Domain Breakdown +{self._format_domains(context['domains'])} + +Generate a complete synthesis with actionable insights. +""" + + # Call LLM + try: + response = await self.llm_client.generate( + system_prompt=SYNTHESIS_SYSTEM_PROMPT, + user_prompt=user_prompt, + temperature=0.7, # Allow some creativity + max_tokens=4000, + ) + + # Parse JSON response + result = json.loads(response) + + # Convert to Synthesis object + return Synthesis( + executive_narrative=result.get("executive_narrative", ""), + sentiment_insight=result.get("sentiment_insight", ""), + category_insight=result.get("category_insight", ""), + timeline_insight=result.get("timeline_insight", ""), + priority_domain=result.get("priority_domain"), + priority_issue=result.get("priority_issue"), + action_plan=[ + ActionItem( + id=a.get("id", f"action_{i}"), + title=a.get("title", ""), + why=a.get("why", ""), + what=a.get("what", ""), + who=a.get("who", ""), + impact=a.get("impact", ""), + evidence=a.get("evidence", []), + estimated_rating_lift=a.get("estimated_rating_lift"), + complexity=a.get("complexity", "medium"), + priority=a.get("priority", "medium"), + timeline=a.get("timeline", "This month"), + related_subcode=a.get("related_subcode", ""), + ) + for i, a in enumerate(result.get("action_plan", [])) + ], + issue_actions={}, # Can be populated from action_plan + timeline_annotations=[ + TimelineAnnotation( + date=t.get("date", ""), + label=t.get("label", ""), + description=t.get("description", ""), + type=t.get("type", "neutral"), + ) + for t in result.get("timeline_annotations", []) + ], + marketing_angles=result.get("marketing_angles", []), + competitor_context=result.get("competitor_context"), + generated_at=datetime.utcnow().isoformat(), + ) + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse LLM response: {e}") + return self._create_fallback_synthesis() + except Exception as e: + logger.error(f"Synthesis generation failed: {e}") + return self._create_fallback_synthesis() + + def _format_sentiment(self, sentiment: list[dict]) -> str: + """Format sentiment data for prompt.""" + lines = [] + for s in sentiment: + valence = s.get("valence", "Unknown") + count = s.get("count", 0) + reviews = s.get("review_count", 0) + label = {"V+": "Positive", "V-": "Negative", "V0": "Neutral", "V±": "Mixed"}.get(valence, valence) + lines.append(f"- {label}: {count} mentions ({reviews} reviews)") + return "\n".join(lines) or "No sentiment data" + + def _format_issues(self, issues: list[dict]) -> str: + """Format issues for prompt.""" + lines = [] + for i, issue in enumerate(issues[:5], 1): + subcode = issue.get("subcode", "") + name = issue.get("subcode_name", "") + domain = issue.get("domain_name", "") + count = issue.get("negative_count", 0) + quotes = issue.get("example_quotes", [])[:2] + + lines.append(f"{i}. [{subcode}] {name} ({domain})") + lines.append(f" - {count} negative mentions") + for q in quotes: + if q: + lines.append(f' - Example: "{q[:100]}..."' if len(q) > 100 else f' - Example: "{q}"') + return "\n".join(lines) or "No issues found" + + def _format_strengths(self, strengths: list[dict]) -> str: + """Format strengths for prompt.""" + lines = [] + for i, strength in enumerate(strengths[:3], 1): + subcode = strength.get("subcode", "") + name = strength.get("subcode_name", "") + domain = strength.get("domain_name", "") + count = strength.get("positive_count", 0) + quotes = strength.get("example_quotes", [])[:2] + + lines.append(f"{i}. [{subcode}] {name} ({domain})") + lines.append(f" - {count} positive mentions") + for q in quotes: + if q: + lines.append(f' - Example: "{q[:100]}..."' if len(q) > 100 else f' - Example: "{q}"') + return "\n".join(lines) or "No strengths found" + + def _format_domains(self, domains: list[dict]) -> str: + """Format domain distribution for prompt.""" + lines = [] + for d in domains: + domain = d.get("domain", "") + name = d.get("domain_name", "") + total = d.get("total_count", 0) + positive = d.get("positive_count", 0) + negative = d.get("negative_count", 0) + lines.append(f"- {domain} ({name}): {total} total ({positive} positive, {negative} negative)") + return "\n".join(lines) or "No domain data" + + def _create_fallback_synthesis(self) -> Synthesis: + """Create a minimal synthesis when LLM fails.""" + return Synthesis( + executive_narrative="Unable to generate detailed analysis. Please review the data manually.", + sentiment_insight="", + category_insight="", + timeline_insight="", + priority_domain=None, + priority_issue=None, + action_plan=[], + issue_actions={}, + timeline_annotations=[], + marketing_angles=[], + competitor_context=None, + generated_at=datetime.utcnow().isoformat(), + ) + + async def _store_synthesis(self, execution_id: str, synthesis: Synthesis) -> None: + """Store synthesis in database.""" + await self.pool.execute(""" + UPDATE pipeline.executions + SET + synthesis = $2, + updated_at = NOW() + WHERE execution_id = $1 + """, execution_id, json.dumps({ + "executive_narrative": synthesis.executive_narrative, + "sentiment_insight": synthesis.sentiment_insight, + "category_insight": synthesis.category_insight, + "timeline_insight": synthesis.timeline_insight, + "priority_domain": synthesis.priority_domain, + "priority_issue": synthesis.priority_issue, + "action_plan": [ + { + "id": a.id, + "title": a.title, + "why": a.why, + "what": a.what, + "who": a.who, + "impact": a.impact, + "evidence": a.evidence, + "estimated_rating_lift": a.estimated_rating_lift, + "complexity": a.complexity, + "priority": a.priority, + "timeline": a.timeline, + "related_subcode": a.related_subcode, + } + for a in synthesis.action_plan + ], + "issue_actions": synthesis.issue_actions, + "timeline_annotations": [ + { + "date": t.date, + "label": t.label, + "description": t.description, + "type": t.type, + } + for t in synthesis.timeline_annotations + ], + "marketing_angles": synthesis.marketing_angles, + "competitor_context": synthesis.competitor_context, + "generated_at": synthesis.generated_at, + })) diff --git a/web/components/reviewiq/DashboardSkeleton.tsx b/web/components/reviewiq/DashboardSkeleton.tsx new file mode 100644 index 0000000..4f4a4c2 --- /dev/null +++ b/web/components/reviewiq/DashboardSkeleton.tsx @@ -0,0 +1,74 @@ +'use client'; + +/** + * Loading skeleton for the ReviewIQ Dashboard. + */ +export function DashboardSkeleton() { + return ( +
+ {/* KPI Cards Skeleton */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ + {/* Charts Grid Skeleton */} +
+
+
+
+
+ + {/* Timeline Skeleton */} +
+ + {/* Tables Skeleton */} +
+
+
+
+
+ ); +} + +/** + * Error state component. + */ +export function DashboardError({ message, onRetry }: { message: string; onRetry?: () => void }) { + return ( +
+
+ Failed to load dashboard +
+

{message}

+ {onRetry && ( + + )} +
+ ); +} + +/** + * Empty state when no job is selected. + */ +export function DashboardEmpty() { + return ( +
+
+ No Job Selected +
+

+ Select a job to view analytics or run the ReviewIQ pipeline first. +

+
+ ); +} diff --git a/web/components/reviewiq/FilterBar.tsx b/web/components/reviewiq/FilterBar.tsx new file mode 100644 index 0000000..59b336a --- /dev/null +++ b/web/components/reviewiq/FilterBar.tsx @@ -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 = { + '7d': 'Last 7 days', + '14d': 'Last 14 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', + '1y': 'Last year', + 'all': 'All time', + }; + + return ( +
+
+ + Active Filters: + + {/* Time Range (only show if not default) */} + {filters.timeRange !== '30d' && ( + + {timeRangeLabels[filters.timeRange]} + + + )} + + {/* Brush Range */} + {filters.brushRange && ( + + {filters.brushRange.start} to {filters.brushRange.end} + + + )} + + {/* Sentiment Filters */} + {filters.sentiment.map((s) => ( + + {s.charAt(0).toUpperCase() + s.slice(1)} + + + ))} + + {/* URT Domain */} + {filters.urtDomain && ( + + {DOMAIN_LABELS[filters.urtDomain] || filters.urtDomain} + + + )} + + {/* Intensity Filters */} + {filters.intensity.map((i) => ( + + {INTENSITY_LABELS[i] || i} Intensity + + + ))} +
+ + +
+ ); +} diff --git a/web/components/reviewiq/ReviewIQDashboard.tsx b/web/components/reviewiq/ReviewIQDashboard.tsx index 182647f..d31ca60 100644 --- a/web/components/reviewiq/ReviewIQDashboard.tsx +++ b/web/components/reviewiq/ReviewIQDashboard.tsx @@ -112,6 +112,7 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) { avgRating={data.overview.avg_rating} domainScores={data.domain_scores} onDomainClick={handleDomainClick} + synthesis={data.synthesis} /> {/* ═══════════════════════════════════════════════════════════════ @@ -119,8 +120,15 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) { Side-by-side: How customers feel + What they talk about ═══════════════════════════════════════════════════════════════ */}
- - + +
{/* ═══════════════════════════════════════════════════════════════ @@ -133,7 +141,11 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) { SECTION 4: TRENDS (Timeline) How things change over time ═══════════════════════════════════════════════════════════════ */} - + {/* ═══════════════════════════════════════════════════════════════ SECTION 5: DEEP DIVE (Tables) diff --git a/web/components/reviewiq/charts/IntensityHeatmap.tsx b/web/components/reviewiq/charts/IntensityHeatmap.tsx index e335059..acebf06 100644 --- a/web/components/reviewiq/charts/IntensityHeatmap.tsx +++ b/web/components/reviewiq/charts/IntensityHeatmap.tsx @@ -7,6 +7,10 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; interface SentimentHeatmapProps { data: URTDomainPoint[]; + // AI-generated insight (optional - shows when available) + insight?: string | null; + // Domain to highlight (optional - from AI priority) + highlightDomain?: string | null; } // User-friendly domain config with emojis and descriptions @@ -51,7 +55,7 @@ const getNegativeColor = (value: number, max: number): string => { * User-friendly design with emojis and clear labels. * Click to filter by domain and sentiment. */ -export function IntensityHeatmap({ data }: SentimentHeatmapProps) { +export function IntensityHeatmap({ data, insight, highlightDomain }: SentimentHeatmapProps) { const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters(); // Check if cross-filters are active @@ -136,6 +140,19 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) { )}
+ {/* AI Insight (when available) */} + {insight && ( +
+
+ +
+
AI Insight
+

{insight}

+
+
+
+ )} + {data.length === 0 ? (
No feedback data available @@ -170,12 +187,15 @@ export function IntensityHeatmap({ data }: SentimentHeatmapProps) { const isDomainActive = filters.urtDomain === row.domain; const isPositiveActive = isDomainActive && filters.sentiment.includes('positive'); const isNegativeActive = isDomainActive && filters.sentiment.includes('negative'); + const isHighlighted = highlightDomain === row.domain; return ( {/* Domain Label */} diff --git a/web/components/reviewiq/charts/SentimentPie.tsx b/web/components/reviewiq/charts/SentimentPie.tsx index f4228e0..3a5e836 100644 --- a/web/components/reviewiq/charts/SentimentPie.tsx +++ b/web/components/reviewiq/charts/SentimentPie.tsx @@ -8,6 +8,8 @@ import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; interface SentimentPieProps { data: SentimentDataPoint[]; + // AI-generated insight (optional - shows when available) + insight?: string | null; } // User-friendly sentiment config @@ -74,7 +76,7 @@ const SENTIMENT_ORDER = ['V+', 'V-', 'V0', 'V±']; * User-friendly design with emojis and clear numbers. * Click to filter by sentiment. */ -export function SentimentPie({ data }: SentimentPieProps) { +export function SentimentPie({ data, insight }: SentimentPieProps) { const { filters, toggleSentiment } = useReviewIQFilters(); // Process data @@ -185,6 +187,19 @@ export function SentimentPie({ data }: SentimentPieProps) {
+ {/* AI Insight (when available) */} + {insight && ( +
+
+ +
+
AI Insight
+

{insight}

+
+
+
+ )} + {processedData.cards.length === 0 ? (
No sentiment data available diff --git a/web/components/reviewiq/charts/TimelineChart.tsx b/web/components/reviewiq/charts/TimelineChart.tsx index ec05b05..63d8c45 100644 --- a/web/components/reviewiq/charts/TimelineChart.tsx +++ b/web/components/reviewiq/charts/TimelineChart.tsx @@ -15,12 +15,16 @@ import { ReferenceLine, } from 'recharts'; import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react'; -import type { TimelinePoint, TimeRange } from '../types'; +import type { TimelinePoint, TimeRange, TimelineAnnotation } from '../types'; import { DOMAIN_LABELS } from '../types'; import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; interface TimelineChartProps { data: TimelinePoint[]; + // AI-generated insight (optional - shows when available) + insight?: string | null; + // Timeline annotations from AI (optional - marks key events) + annotations?: TimelineAnnotation[] | null; } type ViewMode = 'sentiment' | 'volume' | 'rating'; @@ -45,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string * User-friendly design with view toggles and interactive brush. * Responds to domain/sentiment filters. */ -export function TimelineChart({ data }: TimelineChartProps) { +export function TimelineChart({ data, insight, annotations }: TimelineChartProps) { const { filters, setTimeRange, setBrushRange } = useReviewIQFilters(); const [viewMode, setViewMode] = useState('sentiment'); const [localBrushRange, setLocalBrushRange] = useState<{ @@ -264,6 +268,44 @@ export function TimelineChart({ data }: TimelineChartProps) { )}
+ {/* AI Insight (when available) */} + {insight && ( +
+
+ +
+
AI Insight
+

{insight}

+
+
+
+ )} + + {/* Key Events (when annotations available) */} + {annotations && annotations.length > 0 && ( +
+ {annotations.slice(0, 3).map((annotation, idx) => ( +
+ { + annotation.type === 'positive' ? '📈' : + annotation.type === 'negative' ? '📉' : + annotation.type === 'event' ? '📍' : '•' + } + {annotation.label} +
+ ))} +
+ )} + {sortedData.length === 0 ? (
diff --git a/web/components/reviewiq/index.ts b/web/components/reviewiq/index.ts new file mode 100644 index 0000000..6c01440 --- /dev/null +++ b/web/components/reviewiq/index.ts @@ -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'; diff --git a/web/components/reviewiq/insights/ExecutiveSummary.tsx b/web/components/reviewiq/insights/ExecutiveSummary.tsx index 1de65cc..3819479 100644 --- a/web/components/reviewiq/insights/ExecutiveSummary.tsx +++ b/web/components/reviewiq/insights/ExecutiveSummary.tsx @@ -16,7 +16,7 @@ import { Award, } from 'lucide-react'; import { useTranslation } from '@/hooks/useTranslation'; -import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types'; +import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types'; import { getSubcodeDefinition } from '@/lib/taxonomy/data'; interface ExecutiveSummaryProps { @@ -25,6 +25,8 @@ interface ExecutiveSummaryProps { domainScores?: DomainScore[]; onDriverClick?: (subcode: string) => void; onDomainClick?: (domain: URTDomain) => void; + // AI-generated narrative (optional - enhances when available) + synthesis?: Synthesis | null; } // User-friendly domain config @@ -199,10 +201,14 @@ export function ExecutiveSummary({ domainScores, onDriverClick, onDomainClick, + synthesis, }: ExecutiveSummaryProps) { const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights; const [showFullSummary, setShowFullSummary] = useState(false); + // Use AI narrative if available, otherwise fall back to generated summary + const narrativeText = synthesis?.executive_narrative || executive_summary; + const topStrength = strengths[0]; const topWeakness = weaknesses[0]; const ratingDisplay = getRatingDisplay(avgRating); @@ -286,16 +292,23 @@ export function ExecutiveSummary({
{/* AI Summary */} - {executive_summary && ( + {narrativeText && (
-
+
- 💡 -
-

- {executive_summary} + {synthesis?.executive_narrative ? '✨' : '💡'} +

+ {synthesis?.executive_narrative && ( +
AI-Generated Insight
+ )} +

+ {narrativeText}

- {executive_summary.length > 150 && ( + {narrativeText.length > 200 && ( ); })} @@ -109,11 +459,450 @@ function Quadrant({ ); } +// Hover preview card +function HoverCard({ item, position }: { item: OpportunityItem; position: { x: number; y: number } }) { + return ( +
+
+
+
+
{item.name}
+
{item.domain_name}
+
+
+ +
+
+ Negative + {item.negative_pct}% +
+
+
+
+ {item.rating_impact && ( +
+ Rating impact + +{item.rating_impact} +
+ )} +
+ +
+ Click for details → +
+
+ ); +} + +// Render stars for rating +function RatingStars({ rating }: { rating: number }) { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); +} + +// Normalize text for matching (handles spacing differences around punctuation) +function normalizeForMatching(text: string): string { + return text + .toLowerCase() + .replace(/\s+([.,!?;:])/g, '$1') // Remove space before punctuation + .replace(/([.,!?;:])\s+/g, '$1 ') // Normalize space after punctuation + .replace(/\s+/g, ' ') // Multiple spaces to single + .trim(); +} + +// Find span in review using normalized matching, return original indices +function findSpanInReview(reviewText: string, spanText: string): { start: number; end: number } | null { + // Try exact match first (case-insensitive) + const lowerReview = reviewText.toLowerCase(); + const lowerSpan = spanText.toLowerCase(); + let idx = lowerReview.indexOf(lowerSpan); + if (idx !== -1) { + return { start: idx, end: idx + spanText.length }; + } + + // Try normalized matching + const normReview = normalizeForMatching(reviewText); + const normSpan = normalizeForMatching(spanText); + const normIdx = normReview.indexOf(normSpan); + + if (normIdx === -1) return null; + + // Map normalized index back to original text + // Walk through original text, tracking normalized position + let origIdx = 0; + let normPos = 0; + const targetNormStart = normIdx; + const targetNormEnd = normIdx + normSpan.length; + let origStart = -1; + let origEnd = -1; + + while (origIdx <= reviewText.length && normPos <= normReview.length) { + if (normPos === targetNormStart) origStart = origIdx; + if (normPos === targetNormEnd) { + origEnd = origIdx; + break; + } + + if (origIdx < reviewText.length) { + const origChar = reviewText[origIdx]; + const normChar = normReview[normPos]; + + // Skip characters that were removed in normalization + if (origChar.toLowerCase() === normChar) { + origIdx++; + normPos++; + } else if (/\s/.test(origChar)) { + origIdx++; // Skip extra whitespace in original + } else { + origIdx++; + normPos++; + } + } else { + break; + } + } + + if (origStart !== -1 && origEnd === -1) { + origEnd = reviewText.length; + } + + return origStart !== -1 ? { start: origStart, end: origEnd } : null; +} + +// Highlight span text within review text +function HighlightedReviewText({ + reviewText, + spanText, + maxLength = 150, + expanded = false, +}: { + reviewText: string; + spanText: string; + maxLength?: number; + expanded?: boolean; +}) { + // Find the span within the review using fuzzy matching + const match = findSpanInReview(reviewText, spanText); + + if (!match) { + // Span not found, just show truncated review + const displayText = expanded ? reviewText : reviewText.slice(0, maxLength); + return ( + + {displayText} + {!expanded && reviewText.length > maxLength && '...'} + + ); + } + + const spanIndex = match.start; + const spanEnd = match.end; + + // If span covers most of the review (>80%), highlight the whole thing + const spanCoverage = (spanEnd - spanIndex) / reviewText.length; + if (spanCoverage > 0.8) { + const displayText = expanded ? reviewText : reviewText.slice(0, maxLength); + return ( + + {displayText} + {!expanded && reviewText.length > maxLength && '...'} + + ); + } + + // Calculate display window around the span + let displayStart = 0; + let displayEnd = reviewText.length; + + if (!expanded) { + // Show context around the span + const contextBefore = 40; + const contextAfter = 60; + displayStart = Math.max(0, spanIndex - contextBefore); + displayEnd = Math.min(reviewText.length, spanEnd + contextAfter); + + // Adjust to not cut words + if (displayStart > 0) { + const spaceIndex = reviewText.indexOf(' ', displayStart); + if (spaceIndex !== -1 && spaceIndex < spanIndex) { + displayStart = spaceIndex + 1; + } + } + if (displayEnd < reviewText.length) { + const spaceIndex = reviewText.lastIndexOf(' ', displayEnd); + if (spaceIndex !== -1 && spaceIndex > spanEnd) { + displayEnd = spaceIndex; + } + } + } + + const before = reviewText.slice(displayStart, spanIndex); + const highlight = reviewText.slice(spanIndex, spanEnd); + const after = reviewText.slice(spanEnd, displayEnd); + + return ( + + {displayStart > 0 && '...'} + {before} + {highlight} + {after} + {!expanded && displayEnd < reviewText.length && '...'} + + ); +} + +// Google-style review card for customer feedback +function SpanCard({ + span, + onViewReview +}: { + span: OpportunitySpan; + onViewReview?: (reviewId: string, spanId: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const reviewText = span.review_text || span.span_text; + const isLongReview = reviewText.length > 150; + + return ( +
+ {/* Header: Stars + Date + Expand button */} +
+ {span.rating && } + {span.review_date && ( + {span.review_date} + )} + {span.review_id && onViewReview && ( + + )} +
+ + {/* Review text with highlighted span */} +

+ {span.review_text ? ( + + ) : ( + "{span.span_text}" + )} + {isLongReview && !expanded && ( + + )} + {expanded && isLongReview && ( + + )} +

+
+ ); +} + +// Detail panel component +function DetailPanel({ + item, + onClose, + onViewReview, +}: { + item: OpportunityItem; + onClose: () => void; + onViewReview?: (reviewId: string, spanId: string) => void; +}) { + return ( +
+ {/* Header */} +
+
+
+
+

{item.name}

+
{item.domain_name} · {item.subcode}
+
+
+ +
+ + {/* Metrics */} +
+
+
Negative Sentiment
+
{item.negative_pct}%
+
{item.span_count} mentions
+
+ {item.rating_impact && ( +
+
+ + If Fixed +
+
+{item.rating_impact}
+
rating points
+
+ )} +
+ + {/* Complexity */} +
+ + Complexity: + + {COMPLEXITY_LABELS[item.complexity] || item.complexity} + +
+ + {/* Customer Feedback Spans (evidence of the problem) - expands to fill available space */} + {item.spans && item.spans.length > 0 && ( +
+
+ + Customer Feedback ({item.spans.length}) +
+
+ {item.spans.map((span) => ( + + ))} +
+
+ )} + + {/* Solution */} + {item.solution && ( +
+
+ 💡 Suggested Solution +
+

+ {item.solution} +

+
+ )} + + {/* Owner */} + {item.owner && ( +
+ + + Assign to: {item.owner} + +
+ )} + + {/* CTA */} + +
+ ); +} + /** * 2x2 Opportunity Matrix visualization. * Shows issues categorized by frequency vs complexity with coordinate positioning. */ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixProps) { + const [selectedItem, setSelectedItem] = useState(null); + const [hoveredItem, setHoveredItem] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + const [gridSize, setGridSize] = useState(null); + const matrixRef = useRef(null); + const gridContainerRef = useRef(null); + const componentRef = useRef(null); + const [reviewModal, setReviewModal] = useState<{ reviewId: string; spanId: string } | null>(null); + + // Calculate grid size based on available space + useEffect(() => { + const updateGridSize = () => { + // Calculate max available height for the grid (viewport - margins - header - labels - legend) + const maxAvailableHeight = window.innerHeight - 300; + + // Get component width, then subtract detail panel min (280px) + y-axis label (40px) + gaps (32px) + const componentWidth = componentRef.current?.offsetWidth ?? window.innerWidth * 0.6; + const maxAvailableWidth = componentWidth - 280 - 40 - 32; // detail panel min + y-label + padding/gaps + + // Grid must be square - use the smaller of available height or width + const size = Math.min(maxAvailableHeight, maxAvailableWidth); + setGridSize(Math.max(250, size)); + }; + + // Delay initial calculation to allow layout to settle + const timeoutId = setTimeout(updateGridSize, 50); + window.addEventListener('resize', updateGridSize); + + // Also observe component width changes + if (componentRef.current) { + const resizeObserver = new ResizeObserver(updateGridSize); + resizeObserver.observe(componentRef.current); + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', updateGridSize); + resizeObserver.disconnect(); + }; + } + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', updateGridSize); + }; + }, []); + if (!matrix) { return null; } @@ -128,9 +917,29 @@ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixP return null; } + const handleItemClick = (item: OpportunityItem) => { + setSelectedItem(item); + onSubcodeClick?.(item.subcode); + }; + + const handleItemHover = (item: OpportunityItem | null) => { + setHoveredItem(item); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + setMousePos({ x: e.clientX, y: e.clientY }); + }; + return ( -
-
+
+

Opportunity Matrix

@@ -138,23 +947,36 @@ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixP
- {/* Matrix with L-shaped label area */} -
- {/* Y-axis label column */} -
+ {/* Main layout: Matrix + Detail Panel */} +
+ {/* Matrix section */} +
+ {/* Matrix with L-shaped label area */} +
+ {/* Y-axis label column - height matches grid */} +
Effort →
{/* Main matrix area */} -
+
{/* Matrix with arrows */} -
+
{/* Coordinate axes - L-shaped from bottom-left origin */} @@ -204,11 +1026,18 @@ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixP /> -
+
{/* Unified coordinate grid overlay */} @@ -244,7 +1073,7 @@ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixP ))} -
+
{/* Top Row: Simple solutions */} {/* Bottom Row: Complex solutions */} @@ -276,7 +1109,9 @@ export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixP borderColor="border-purple-200" textColor="text-purple-700" iconBg="bg-purple-200" - onItemClick={onSubcodeClick} + onItemClick={handleItemClick} + onItemHover={handleItemHover} + selectedItem={selectedItem} />
{/* X-axis label row */} -
+
Frequency →
+ + {/* Legend */} +
+
+
+ Quick Wins +
+
+
+ Critical +
+
+
+ Strategic +
+
+
+ Nice to Have +
+
+
+
+
+ + {/* Detail Panel - expands horizontally, height matches grid + labels */} +
+ {selectedItem ? ( + setSelectedItem(null)} + onViewReview={(reviewId, spanId) => setReviewModal({ reviewId, spanId })} + /> + ) : ( +
+
+ +
+

Select an opportunity

+

+ Click on any item in the matrix to see details and suggested actions +

+
+ )}
- {/* Legend */} -
-
-
- Quick Wins: Fix first -
-
-
- Critical: High impact -
-
-
- Strategic: Plan ahead -
-
-
- Nice to Have: Low priority -
-
+ {/* Hover Card */} + {hoveredItem && !selectedItem && ( + + )} + + {/* Review Modal */} + {reviewModal && ( + setReviewModal(null)} + /> + )}
); } diff --git a/web/components/reviewiq/insights/RatingSimulator.tsx b/web/components/reviewiq/insights/RatingSimulator.tsx new file mode 100644 index 0000000..94099b9 --- /dev/null +++ b/web/components/reviewiq/insights/RatingSimulator.tsx @@ -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 ( +
+
{label}
+
+ {/* Full stars */} + {Array.from({ length: fullStars }).map((_, i) => ( + + ))} + {/* Partial star */} + {partialStar > 0 && ( +
+ +
+ +
+
+ )} + {/* Empty stars */} + {Array.from({ length: emptyStars }).map((_, i) => ( + + ))} +
+
+ {rating.toFixed(2)} +
+
+ ); +} + +/** + * 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 ( +
+
+
+ +
+

Rating Simulator

+ + + +{potential_gain.toFixed(2)} potential + +
+ + {/* Rating Comparisons */} +
+ + {if_fix_top_1 && ( + + )} + {if_fix_top_3 && ( + + )} +
+ + {/* Progress Bar */} +
+
+ Progress to 5 Stars + {Math.min(100, potentialProgress).toFixed(0)}% achievable +
+
+ {/* Current rating progress */} +
+ {/* Potential gain overlay */} +
+
+
+ 1 + 2 + 3 + 4 + 5 +
+
+ + {/* Action Items */} + {topWeaknesses.length > 0 && ( +
+
+ + Priority Fixes +
+
+ {topWeaknesses.slice(0, 3).map((weakness, index) => ( +
+
+ {index + 1} +
+
+ + {weakness.subcode_name} + + + {weakness.negative_percentage.toFixed(0)}% negative + {weakness.projected_rating_impact && ( + + +{weakness.projected_rating_impact.toFixed(2)} if fixed + + )} + +
+ {weakness.solution_complexity && ( + + {weakness.solution_complexity} + + )} +
+ ))} +
+
+ )} + + {/* CTA */} +
+
+ + + Fixing the top 3 issues could boost your rating by{' '} + {((if_fix_top_3 || current_rating) - current_rating).toFixed(2)} stars + +
+
+
+ ); +} diff --git a/web/components/reviewiq/insights/StrengthsWeaknesses.tsx b/web/components/reviewiq/insights/StrengthsWeaknesses.tsx new file mode 100644 index 0000000..d6ad655 --- /dev/null +++ b/web/components/reviewiq/insights/StrengthsWeaknesses.tsx @@ -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 ( +
+

Not enough data to identify strengths and weaknesses.

+

More reviews are needed for analysis.

+
+ ); + } + + return ( +
+ {/* Strengths Panel */} +
+
+
+ +

Your Strengths

+
+

Protect & amplify these

+
+ +
+ {strengths.length === 0 ? ( +
+ No strong positive patterns detected yet. +
+ ) : ( + strengths.map((strength) => ( + + )) + )} +
+
+ + {/* Weaknesses Panel */} +
+
+
+ +

Areas to Improve

+
+

Fix these to boost rating

+
+ +
+ {weaknesses.length === 0 ? ( +
+ No significant issues detected. Great job! +
+ ) : ( + weaknesses.map((weakness) => ( + + )) + )} +
+
+
+ ); +} diff --git a/web/components/reviewiq/insights/index.ts b/web/components/reviewiq/insights/index.ts new file mode 100644 index 0000000..c7f01a2 --- /dev/null +++ b/web/components/reviewiq/insights/index.ts @@ -0,0 +1,4 @@ +export { ExecutiveSummary } from './ExecutiveSummary'; +export { StrengthsWeaknesses } from './StrengthsWeaknesses'; +export { OpportunityMatrix } from './OpportunityMatrix'; +export { RatingSimulator } from './RatingSimulator'; diff --git a/web/components/reviewiq/kpi/DomainScores.tsx b/web/components/reviewiq/kpi/DomainScores.tsx new file mode 100644 index 0000000..768e452 --- /dev/null +++ b/web/components/reviewiq/kpi/DomainScores.tsx @@ -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 = { + 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 ( +
+ {/* Header Section */} +
+
+

What Customers Talk About

+

+ How you're performing in each area of the customer experience +

+
+ + {/* Overall Experience Index */} + {overallIndex !== null && ( +
+ + Overall Score + +
+ + {overallIndex.toFixed(0)} + + /100 +
+
+ )} +
+ + {/* Summary Stats */} +
+
+
+ +
+
+
{totalPositive}
+
Happy comments
+
+
+
+
+ +
+
+
{totalNegative}
+
Complaints
+
+
+
+
+ +
+
+
{totalMentions}
+
Topics analyzed
+
+
+
+ + {/* How to read this */} +
+ +

+ Score = % positive feedback. Higher is better. Based on {totalMentions.toLocaleString()} things customers mentioned in their reviews. +

+
+ + {/* Domain Score Cards */} +
+ {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 ( + + ); + })} +
+ + {/* Threshold Legend */} +
+
+ Click any area to filter the dashboard +
+
+
+
+ <50% +
+
+
+ 50-69% +
+
+
+ ≥70% +
+
+
+
+ ); +} diff --git a/web/components/reviewiq/kpi/KPICard.tsx b/web/components/reviewiq/kpi/KPICard.tsx new file mode 100644 index 0000000..475387a --- /dev/null +++ b/web/components/reviewiq/kpi/KPICard.tsx @@ -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 ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + onClick?.(); + } + }} + > +
+
+ + {title} +
+ {isActive && ( + + ACTIVE + + )} +
+
{value}
+ {subtitle &&
{subtitle}
} +
+ ); +} diff --git a/web/components/reviewiq/kpi/KPISection.tsx b/web/components/reviewiq/kpi/KPISection.tsx new file mode 100644 index 0000000..7418b26 --- /dev/null +++ b/web/components/reviewiq/kpi/KPISection.tsx @@ -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 ( +
+ {/* Total Reviews */} + + + {/* Total Spans */} + + + {/* Open Issues */} + + + {/* Average Rating */} + + + {/* Positive Count */} + toggleSentiment('positive')} + isActive={positiveActive} + /> + + {/* Negative Count */} + toggleSentiment('negative')} + isActive={negativeActive} + /> + + {/* Neutral Count */} + toggleSentiment('neutral')} + isActive={filters.sentiment.includes('neutral')} + /> + + {/* Mixed Count */} + +
+ ); +} diff --git a/web/components/reviewiq/tables/IssueDetailModal.tsx b/web/components/reviewiq/tables/IssueDetailModal.tsx new file mode 100644 index 0000000..dca37a2 --- /dev/null +++ b/web/components/reviewiq/tables/IssueDetailModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+

+ {issue.subcode_name || issue.primary_subcode} +

+ {issue.primary_subcode} +
+ + {issue.state.toUpperCase()} + +
+ +
+
+ + {/* Content */} +
+ {/* Issue Info Grid */} +
+
+
+ + URT Code +
+ + {issue.primary_subcode} + +
+ +
+
+ + Domain +
+ + {DOMAIN_LABELS[issue.domain] || issue.domain} + +
+ +
+
+ + Priority +
+ + {(issue.priority_score * 100).toFixed(0)}% + +
+ +
+
+ + Intensity +
+ + {issue.max_intensity + ? INTENSITY_LABELS[issue.max_intensity] || issue.max_intensity + : 'N/A'} + +
+
+ + {/* Entity */} + {issue.entity && ( +
+ Related Entity +

{issue.entity}

+
+ )} + + {/* Solution & Owner Section */} + {(issue.solution || issue.default_owner) && ( +
+ {issue.solution && ( +
+
+ + Recommended Solution + {issue.solution_complexity && ( + + {issue.solution_complexity.charAt(0).toUpperCase() + issue.solution_complexity.slice(1)} Complexity + + )} +
+

{issue.solution}

+
+ )} + + {issue.default_owner && ( +
+ + + Assign to:{' '} + {issue.default_owner} + +
+ )} +
+ )} + + {/* Related Spans */} +
+
+

+ Related Spans ({issue.span_count}) +

+
+ + {loading ? ( +
+
+
+ ) : error ? ( +
+ Failed to load spans: {error} +
+ ) : spans.length === 0 ? ( +
+ No spans found for this issue +
+ ) : ( +
+ {spans.map((span: SpanItem) => ( +
+
+

{span.span_text}

+
+ {span.valence && ( + + {VALENCE_LABELS[span.valence] || span.valence} + + )} + {span.intensity && ( + + {INTENSITY_LABELS[span.intensity] || span.intensity} + + )} +
+
+
+
+ {span.urt_primary && ( + {span.urt_primary} + )} + {span.review_time && ( + {new Date(span.review_time).toLocaleDateString()} + )} + {span.entity && Entity: {span.entity}} +
+ {/* View Full Review Button */} + {span.source_review_id && ( + + )} +
+
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ +
+
+ + {/* Review Modal for drill-down */} + {selectedReview && ( + setSelectedReview(null)} + /> + )} +
+ ); +} diff --git a/web/components/reviewiq/tables/IssuesTable.tsx b/web/components/reviewiq/tables/IssuesTable.tsx new file mode 100644 index 0000000..1a8157a --- /dev/null +++ b/web/components/reviewiq/tables/IssuesTable.tsx @@ -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([ + { id: 'priority_score', desc: true }, + ]); + const [selectedIssue, setSelectedIssue] = useState(null); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'primary_subcode', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.subcode_name || row.original.primary_subcode} + + + {row.original.primary_subcode} + +
+ ), + }, + { + accessorKey: 'domain', + header: 'Domain', + cell: ({ row }) => ( + + {DOMAIN_LABELS[row.original.domain] || row.original.domain} + + ), + }, + { + accessorKey: 'default_owner', + header: 'Owner', + cell: ({ row }) => ( + + {row.original.default_owner || 'Unassigned'} + + ), + }, + { + accessorKey: 'state', + header: 'State', + cell: ({ row }) => ( + + {row.original.state.toUpperCase()} + + ), + }, + { + accessorKey: 'priority_score', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {(row.original.priority_score * 100).toFixed(0)}% + + ), + }, + { + accessorKey: 'span_count', + header: 'Spans', + cell: ({ row }) => ( + + {row.original.span_count} + + ), + }, + { + accessorKey: 'max_intensity', + header: 'Intensity', + cell: ({ row }) => ( + + {row.original.max_intensity + ? INTENSITY_LABELS[row.original.max_intensity] || row.original.max_intensity + : '-'} + + ), + }, + { + id: 'actions', + header: '', + cell: ({ row }) => ( + + ), + }, + ], + [] + ); + + 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 ( +
+
+
+

Issues

+

+ {issues.total} total issues - Click row for details +

+
+
+ + {issues.items.length === 0 ? ( +
+ No issues found +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + setSelectedIssue(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ + {/* Pagination */} +
+
+ Page {issues.page} of {totalPages} ({issues.total} issues) +
+
+ + +
+
+ + )} + + {/* Detail Modal */} + {selectedIssue && ( + setSelectedIssue(null)} + /> + )} +
+ ); +} diff --git a/web/components/reviewiq/tables/ReviewModal.tsx b/web/components/reviewiq/tables/ReviewModal.tsx new file mode 100644 index 0000000..136c3c9 --- /dev/null +++ b/web/components/reviewiq/tables/ReviewModal.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+ {/* Header */} +
+

Full Review

+ +
+ + {/* Content */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+
+ ) : review ? ( +
+ {/* Review Metadata */} +
+
+ {/* Author */} + {review.author_name && ( +
+ + {review.author_name} +
+ )} + {/* Business */} + {review.business_name && ( +
+ + {review.business_name} +
+ )} + {/* Date */} + {review.review_time && ( +
+ + {new Date(review.review_time).toLocaleDateString()} +
+ )} +
+ + {/* Rating */} + {review.rating !== null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} +
+ + {/* Review Text with Highlighted Spans */} +
+

+ {Array.isArray(highlightedText) ? ( + highlightedText.map((segment, idx) => + segment.span ? ( + + {segment.text} + + ) : ( + {segment.text} + ) + ) + ) : ( + highlightedText + )} +

+
+ + {/* Legend */} +
+
+ + Positive +
+
+ + Neutral +
+
+ + Negative +
+ | + Underline = URT Domain +
+ + {/* Classified Spans List */} +
+

+ Classified Spans ({review.spans.length}) +

+
+ {review.spans.map((span) => ( +
+
+

+ “{span.span_text}” +

+
+ {/* URT Code */} + {span.urt_primary && ( + + {span.urt_primary} + + )} + {/* Valence */} + {span.valence && ( + + {VALENCE_LABELS[span.valence]} + + )} + {/* Intensity */} + {span.intensity && ( + + {INTENSITY_LABELS[span.intensity]} + + )} +
+
+ {/* Domain label */} + {span.urt_primary && ( +
+ {DOMAIN_LABELS[span.urt_primary[0]]} Domain + {span.entity && ` · Entity: ${span.entity}`} +
+ )} +
+ ))} +
+
+ + {/* External Link */} + {review.review_url && ( + + )} +
+ ) : null} +
+
+
+ ); +} diff --git a/web/components/reviewiq/tables/SpansTable.tsx b/web/components/reviewiq/tables/SpansTable.tsx new file mode 100644 index 0000000..841f310 --- /dev/null +++ b/web/components/reviewiq/tables/SpansTable.tsx @@ -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([]); + const [expanded, setExpanded] = useState>({}); + const [selectedReview, setSelectedReview] = useState<{ + reviewId: string; + spanId: string; + } | null>(null); + + const columns = useMemo[]>( + () => [ + { + id: 'expander', + header: '', + cell: ({ row }) => ( + + ), + }, + { + accessorKey: 'span_text', + header: 'Text', + cell: ({ row }) => ( +
+

+ {row.original.span_text} +

+
+ ), + }, + { + accessorKey: 'urt_primary', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.urt_primary || '-'} + + ), + }, + { + accessorKey: 'valence', + header: 'Sentiment', + cell: ({ row }) => { + const valence = row.original.valence; + if (!valence) return -; + return ( + + {VALENCE_LABELS[valence] || valence} + + ); + }, + }, + { + accessorKey: 'intensity', + header: 'Intensity', + cell: ({ row }) => ( + + {row.original.intensity + ? INTENSITY_LABELS[row.original.intensity] || row.original.intensity + : '-'} + + ), + }, + { + accessorKey: 'review_time', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.review_time + ? new Date(row.original.review_time).toLocaleDateString() + : '-'} + + ), + }, + ], + [] + ); + + 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) => { + const span = row.original; + return ( + + +
+
+ Full Text +

+ {span.span_text} +

+
+
+
+ Span ID +

{span.span_id}

+
+
+ Review ID +

+ {span.source_review_id || '-'} +

+
+
+ Entity +

{span.entity || '-'}

+
+
+ Domain +

+ {span.urt_primary + ? DOMAIN_LABELS[span.urt_primary[0]] || span.urt_primary[0] + : '-'} +

+
+
+ {/* View Full Review Button */} + {span.source_review_id && ( +
+ +
+ )} +
+ + + ); + }; + + return ( +
+
+
+

Classified Spans

+

+ {spans.total} total spans - Click row to expand +

+
+
+ + {spans.items.length === 0 ? ( +
+ No spans found +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + row.toggleExpanded()} + > + {row.getVisibleCells().map((cell) => ( + + ))} + + {row.getIsExpanded() && renderExpandedRow(row)} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+ + {/* Pagination */} +
+
+ Page {spans.page} of {totalPages} ({spans.total} spans) +
+
+ + +
+
+ + )} + + {/* Review Modal for drill-down */} + setSelectedReview(null)} + /> +
+ ); +} diff --git a/web/components/reviewiq/types.ts b/web/components/reviewiq/types.ts index e9c5913..742ec37 100644 --- a/web/components/reviewiq/types.ts +++ b/web/components/reviewiq/types.ts @@ -99,6 +99,8 @@ export interface WeaknessItem { solution_complexity: string | null; projected_rating_impact: number | null; owner: string | null; + // Optional: example quotes from reviews + example_spans?: OpportunitySpan[]; } export interface RatingSimulator { @@ -108,10 +110,31 @@ export interface RatingSimulator { potential_gain: number; } +export interface OpportunitySpan { + span_id: string; + span_text: string; + review_text: string | null; + rating: number | null; + review_id: string | null; + review_date: string | null; +} + export interface OpportunityItem { subcode: string; + name: string; // Human-readable subcode name x: number; // 0-1, frequency position within quadrant y: number; // 0-1, effort position within quadrant + // Detail data for hover/click + domain: string; + domain_name: string; + negative_pct: number; + span_count: number; + solution: string | null; + complexity: string; + rating_impact: number | null; + owner: string | null; + example: string | null; + spans: OpportunitySpan[]; } export interface OpportunityMatrix { @@ -129,6 +152,58 @@ export interface Insights { executive_summary: string; } +// ==================== AI Synthesis (Stage 4 Output) ==================== + +export interface ActionItem { + id: string; + title: string; + why: string; // Root cause from reviews + what: string; // Specific action to take + who: string; // Department/role responsible + impact: string; // Expected outcome + evidence: string[]; // Example review quotes + estimated_rating_lift: number | null; + complexity: 'quick' | 'medium' | 'complex'; + priority: 'critical' | 'high' | 'medium' | 'low'; + timeline: string; // e.g., "This week", "This month" + related_subcode: string; // URT subcode this addresses +} + +export interface TimelineAnnotation { + date: string; + label: string; + description: string; + type: 'positive' | 'negative' | 'neutral' | 'event'; +} + +export interface Synthesis { + // Narrative insights for each section + executive_narrative: string; // Main story for exec summary + sentiment_insight: string; // Why sentiment is this way + category_insight: string; // Pattern in categories + timeline_insight: string; // What's changing over time + + // Highlights and focus areas + priority_domain: string | null; // Domain needing most attention + priority_issue: string | null; // Issue to fix first + + // Actionable recommendations + action_plan: ActionItem[]; // Prioritized actions + issue_actions: Record; // issue_id → recommended action + + // Timeline context + timeline_annotations: TimelineAnnotation[]; + + // Marketing opportunities + marketing_angles: string[]; // Ways to promote strengths + + // Competitor context (if available) + competitor_context: string | null; + + // Generated at + generated_at: string; +} + // ==================== Issues (Enriched) ==================== export interface IssueItem { @@ -232,6 +307,8 @@ export interface ReviewIQAnalyticsResponse { spans: PaginatedSpans; timeline: TimelinePoint[]; filters_applied: Record; + // NEW: AI-generated synthesis (optional, null if not generated yet) + synthesis: Synthesis | null; } // ==================== Filter Types ==================== @@ -319,6 +396,16 @@ export const DOMAIN_LABELS: Record = { R: 'Relationship', }; +export const DOMAIN_FRIENDLY: Record = { + 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 = { O: 'Operations / Product', P: 'HR / Training', @@ -336,7 +423,7 @@ export const INTENSITY_LABELS: Record = { }; export const COMPLEXITY_LABELS: Record = { - simple: 'Quick Fix', + quick: 'Quick Fix', medium: 'Moderate Effort', complex: 'Strategic Initiative', };