diff --git a/web/components/reviewiq/ReviewIQDashboard.tsx b/web/components/reviewiq/ReviewIQDashboard.tsx new file mode 100644 index 0000000..182647f --- /dev/null +++ b/web/components/reviewiq/ReviewIQDashboard.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState } from 'react'; +import { RefreshCw, BarChart3 } from 'lucide-react'; +import { ReviewIQFilterProvider, useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; +import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics'; +import { FilterBar } from './FilterBar'; +import { DashboardSkeleton, DashboardError, DashboardEmpty } from './DashboardSkeleton'; +import { SentimentPie } from './charts/SentimentPie'; +import { IntensityHeatmap } from './charts/IntensityHeatmap'; +import { TimelineChart } from './charts/TimelineChart'; +import { IssuesTable } from './tables/IssuesTable'; +import { SpansTable } from './tables/SpansTable'; +import { ExecutiveSummary } from './insights/ExecutiveSummary'; +import { OpportunityMatrix } from './insights/OpportunityMatrix'; +import type { URTDomain } from './types'; + +interface ReviewIQDashboardProps { + jobId?: string | null; + businessId?: string | null; +} + +/** + * Inner dashboard component that uses the filter context. + * + * Streamlined flow (no redundancy): + * 1. Hero: Executive Summary (rating, AI insights, #1 problem/strength, top complaints) + * 2. Explore: Sentiment + Category Heatmap (side by side) + * 3. Action: Opportunity Matrix (what to fix) + * 4. Trends: Timeline + * 5. Deep Dive: Issues & Spans tables + */ +function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) { + const { filters, setURTDomain } = useReviewIQFilters(); + const [issuesPage, setIssuesPage] = useState(1); + const [spansPage, setSpansPage] = useState(1); + + const { data, loading, error, refetch } = useReviewIQAnalytics({ + jobId, + businessId, + filters, + issuesPage, + issuesPageSize: 10, + spansPage, + spansPageSize: 10, + }); + + const handleIssuesPageChange = (page: number) => setIssuesPage(page); + const handleSpansPageChange = (page: number) => setSpansPage(page); + + // No job selected + if (!jobId && !businessId) { + return ; + } + + // Loading state + if (loading && !data) { + return ; + } + + // Error state + if (error) { + return ; + } + + // No data + if (!data) { + return ; + } + + // Handle domain click for filtering + const handleDomainClick = (domain: URTDomain) => { + setURTDomain(filters.urtDomain === domain ? null : domain); + }; + + return ( +
+ {/* ═══════════════════════════════════════════════════════════════ + HEADER + ═══════════════════════════════════════════════════════════════ */} +
+
+
+ +
+
+

ReviewIQ Analytics

+

+ {data.overview.total_reviews.toLocaleString()} reviews • {data.overview.total_spans.toLocaleString()} insights extracted +

+
+
+ +
+ + {/* Active Filters Bar */} + + + {/* ═══════════════════════════════════════════════════════════════ + SECTION 1: EXECUTIVE SUMMARY (Hero) + Rating, AI summary, #1 Problem, #1 Strength, Top Complaints + ═══════════════════════════════════════════════════════════════ */} + + + {/* ═══════════════════════════════════════════════════════════════ + SECTION 2: EXPLORE (Sentiment + Categories) + Side-by-side: How customers feel + What they talk about + ═══════════════════════════════════════════════════════════════ */} +
+ + +
+ + {/* ═══════════════════════════════════════════════════════════════ + SECTION 3: ACTION (Opportunity Matrix) + What to fix - prioritized by impact vs effort + ═══════════════════════════════════════════════════════════════ */} + + + {/* ═══════════════════════════════════════════════════════════════ + SECTION 4: TRENDS (Timeline) + How things change over time + ═══════════════════════════════════════════════════════════════ */} + + + {/* ═══════════════════════════════════════════════════════════════ + SECTION 5: DEEP DIVE (Tables) + Detailed issues and individual mentions + ═══════════════════════════════════════════════════════════════ */} +
+ + +
+ + {/* Debug Info (dev only) */} + {process.env.NODE_ENV === 'development' && ( +
+ + Debug: Filters Applied + +
+            {JSON.stringify(data.filters_applied, null, 2)}
+          
+
+ )} +
+ ); +} + +/** + * Main ReviewIQ Dashboard with filter context provider. + */ +export function ReviewIQDashboard({ jobId, businessId }: ReviewIQDashboardProps) { + return ( + + + + ); +} diff --git a/web/components/reviewiq/charts/IntensityHeatmap.tsx b/web/components/reviewiq/charts/IntensityHeatmap.tsx new file mode 100644 index 0000000..e335059 --- /dev/null +++ b/web/components/reviewiq/charts/IntensityHeatmap.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { useMemo } from 'react'; +import { Filter, ThumbsUp, ThumbsDown, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import type { URTDomainPoint, URTDomain, Sentiment } from '../types'; +import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; + +interface SentimentHeatmapProps { + data: URTDomainPoint[]; +} + +// User-friendly domain config with emojis and descriptions +const DOMAIN_CONFIG: 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' }, +}; + +// Ordered domains by typical business priority +const DOMAIN_ORDER = ['P', 'V', 'J', 'O', 'A', 'E', 'R']; + +// Color scales +const getPositiveColor = (value: number, max: number): string => { + if (max === 0 || value === 0) return '#f3f4f6'; + const intensity = value / max; + if (intensity < 0.25) return '#dcfce7'; // Light green + if (intensity < 0.5) return '#86efac'; + if (intensity < 0.75) return '#22c55e'; + return '#15803d'; // Dark green +}; + +const getNegativeColor = (value: number, max: number): string => { + if (max === 0 || value === 0) return '#f3f4f6'; + const intensity = value / max; + if (intensity < 0.25) return '#fee2e2'; // Light red + if (intensity < 0.5) return '#fca5a5'; + if (intensity < 0.75) return '#ef4444'; + return '#b91c1c'; // Dark red +}; + +/** + * Sentiment Heatmap - Shows Praise vs Complaints by Domain. + * User-friendly design with emojis and clear labels. + * Click to filter by domain and sentiment. + */ +export function IntensityHeatmap({ data }: SentimentHeatmapProps) { + const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters(); + + // Check if cross-filters are active + const hasSentimentFilter = filters.sentiment.length > 0; + const hasDomainFilter = filters.urtDomain !== null; + const hasCrossFilter = hasSentimentFilter || hasDomainFilter; + + // Process and sort data + const processedData = useMemo(() => { + // Create lookup map + const lookup = new Map(); + let maxPositive = 0; + let maxNegative = 0; + + data.forEach((d) => { + lookup.set(d.domain, d); + if (d.positive_count > maxPositive) maxPositive = d.positive_count; + if (d.negative_count > maxNegative) maxNegative = d.negative_count; + }); + + // Sort by domain order, then build rows + const rows = DOMAIN_ORDER + .filter(domain => lookup.has(domain)) + .map(domain => { + const d = lookup.get(domain)!; + const total = d.positive_count + d.negative_count + d.neutral_count; + const positiveRatio = total > 0 ? d.positive_count / total : 0; + const negativeRatio = total > 0 ? d.negative_count / total : 0; + + // Determine trend indicator + let trend: 'up' | 'down' | 'neutral' = 'neutral'; + if (positiveRatio > 0.6) trend = 'up'; + else if (negativeRatio > 0.4) trend = 'down'; + + return { + domain: d.domain, + config: DOMAIN_CONFIG[d.domain] || { + emoji: '📊', + label: d.domain_name || d.domain, + description: '' + }, + positive: d.positive_count, + negative: d.negative_count, + total, + positiveRatio, + negativeRatio, + trend, + }; + }); + + return { rows, maxPositive, maxNegative }; + }, [data]); + + const handleCellClick = (domain: string, sentiment: 'positive' | 'negative') => { + setURTDomain(domain as URTDomain); + // Clear other sentiments and set the clicked one + if (!filters.sentiment.includes(sentiment)) { + toggleSentiment(sentiment); + } + }; + + const handleDomainClick = (domain: string) => { + setURTDomain(domain as URTDomain); + }; + + return ( +
+
+
+

Feedback by Category

+

Click any cell to filter reviews

+
+ {hasCrossFilter && ( +
+ + Filtered +
+ )} +
+ + {data.length === 0 ? ( +
+ No feedback data available +
+ ) : ( +
+ + + + + + + + + + + {processedData.rows.map((row) => { + const isDomainActive = filters.urtDomain === row.domain; + const isPositiveActive = isDomainActive && filters.sentiment.includes('positive'); + const isNegativeActive = isDomainActive && filters.sentiment.includes('negative'); + + return ( + + {/* Domain Label */} + + + {/* Praise Cell */} + + + {/* Complaints Cell */} + + + {/* Health Indicator */} + + + ); + })} + +
+ Category + +
+ + Praise +
+
+
+ + Complaints +
+
+ Health +
+ + + + + + +
+ {row.trend === 'up' && ( +
+ +
+ )} + {row.trend === 'down' && ( +
+ +
+ )} + {row.trend === 'neutral' && ( +
+ +
+ )} +
+
+ + {/* Legend */} +
+
+
+ Praise scale: +
+
+
+
+
+
+
+
+ Complaints scale: +
+
+
+
+
+
+
+
+
+ = needs attention +
+
+
+ )} +
+ ); +} diff --git a/web/components/reviewiq/charts/SentimentPie.tsx b/web/components/reviewiq/charts/SentimentPie.tsx new file mode 100644 index 0000000..f4228e0 --- /dev/null +++ b/web/components/reviewiq/charts/SentimentPie.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { useMemo } from 'react'; +import { X, Filter, Smile, Frown, Meh, AlertTriangle } from 'lucide-react'; +import type { SentimentDataPoint, Sentiment } from '../types'; +import { DOMAIN_LABELS } from '../types'; +import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; + +interface SentimentPieProps { + data: SentimentDataPoint[]; +} + +// User-friendly sentiment config +const SENTIMENT_CONFIG: Record = { + 'V+': { + emoji: '😊', + icon: Smile, + label: 'Happy', + description: 'Positive experiences', + color: '#22c55e', + bgColor: '#dcfce7', + borderColor: '#86efac', + }, + 'V-': { + emoji: '😟', + icon: Frown, + label: 'Unhappy', + description: 'Negative experiences', + color: '#ef4444', + bgColor: '#fee2e2', + borderColor: '#fca5a5', + }, + 'V0': { + emoji: '😐', + icon: Meh, + label: 'Neutral', + description: 'Factual mentions', + color: '#eab308', + bgColor: '#fef9c3', + borderColor: '#fde047', + }, + 'V±': { + emoji: '🤔', + icon: AlertTriangle, + label: 'Mixed', + description: 'Both good & bad', + color: '#f97316', + bgColor: '#ffedd5', + borderColor: '#fdba74', + }, +}; + +// Map valence codes to sentiment filter values +const valenceToSentiment: Record = { + 'V+': 'positive', + 'V0': 'neutral', + 'V-': 'negative', + 'V±': 'negative', // Mixed is treated as negative for filtering +}; + +// Display order +const SENTIMENT_ORDER = ['V+', 'V-', 'V0', 'V±']; + +/** + * Sentiment Overview - Visual cards showing how customers feel. + * User-friendly design with emojis and clear numbers. + * Click to filter by sentiment. + */ +export function SentimentPie({ data }: SentimentPieProps) { + const { filters, toggleSentiment } = useReviewIQFilters(); + + // Process data + const processedData = useMemo(() => { + const lookup = new Map(); + let totalReviews = 0; + let totalMentions = 0; + + data.forEach((d) => { + lookup.set(d.valence, d); + totalReviews += d.review_count; + totalMentions += d.count; + }); + + // Build cards in order + const cards = SENTIMENT_ORDER + .filter(valence => lookup.has(valence)) + .map(valence => { + const d = lookup.get(valence)!; + const config = SENTIMENT_CONFIG[valence]; + + return { + valence, + config, + reviewCount: d.review_count, + mentionCount: d.count, + percentage: d.percentage, + }; + }); + + // Calculate overall sentiment score (0-100) + const positive = lookup.get('V+'); + const negative = lookup.get('V-'); + const posCount = positive?.review_count || 0; + const negCount = negative?.review_count || 0; + const sentimentScore = totalReviews > 0 + ? Math.round(((posCount - negCount) / totalReviews + 1) * 50) + : 50; + + return { cards, totalReviews, totalMentions, sentimentScore }; + }, [data]); + + const handleClick = (valence: string) => { + const sentiment = valenceToSentiment[valence]; + if (sentiment) { + toggleSentiment(sentiment); + } + }; + + const isFiltering = filters.sentiment.length > 0; + const hasDomainFilter = filters.urtDomain !== null; + + // Determine sentiment indicator color + const getScoreColor = () => { + if (processedData.sentimentScore >= 60) return 'text-green-600'; + if (processedData.sentimentScore >= 40) return 'text-yellow-600'; + return 'text-red-600'; + }; + + const getScoreEmoji = () => { + if (processedData.sentimentScore >= 70) return '🎉'; + if (processedData.sentimentScore >= 55) return '👍'; + if (processedData.sentimentScore >= 45) return '😐'; + if (processedData.sentimentScore >= 30) return '😕'; + return '😰'; + }; + + return ( +
+
+
+

How Customers Feel

+

+ {processedData.totalReviews.toLocaleString()} reviews analyzed +

+
+
+ {hasDomainFilter && !isFiltering && ( + + + {DOMAIN_LABELS[filters.urtDomain!]} + + )} + {isFiltering && ( +
+ + {filters.sentiment.join(', ')} + + +
+ )} +
+
+ + {processedData.cards.length === 0 ? ( +
+ No sentiment data available +
+ ) : ( + <> + {/* Overall Sentiment Score */} +
+
+
{getScoreEmoji()}
+
+ {processedData.sentimentScore}% +
+
Sentiment Score
+
+
+ + {/* Sentiment Cards Grid */} +
+ {processedData.cards.map((card) => { + const sentiment = valenceToSentiment[card.valence]; + const isActive = sentiment && filters.sentiment.includes(sentiment); + const Icon = card.config.icon; + + return ( + + ); + })} +
+ + {/* Tip */} +
+ Click any card to filter reviews by sentiment +
+ + )} +
+ ); +} diff --git a/web/components/reviewiq/charts/TimelineChart.tsx b/web/components/reviewiq/charts/TimelineChart.tsx new file mode 100644 index 0000000..ec05b05 --- /dev/null +++ b/web/components/reviewiq/charts/TimelineChart.tsx @@ -0,0 +1,488 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { + ComposedChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Brush, + Area, + ReferenceLine, +} from 'recharts'; +import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react'; +import type { TimelinePoint, TimeRange } from '../types'; +import { DOMAIN_LABELS } from '../types'; +import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; + +interface TimelineChartProps { + data: TimelinePoint[]; +} + +type ViewMode = 'sentiment' | 'volume' | 'rating'; + +const VIEW_OPTIONS: { value: ViewMode; emoji: string; label: string; description: string }[] = [ + { value: 'sentiment', emoji: '😊', label: 'Sentiment', description: 'Positive vs Negative over time' }, + { value: 'volume', emoji: '📊', label: 'Volume', description: 'Review & mention counts' }, + { value: 'rating', emoji: '⭐', label: 'Rating', description: 'Average rating trend' }, +]; + +const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string }[] = [ + { value: '7d', label: '7D', description: 'Last 7 days' }, + { value: '14d', label: '2W', description: 'Last 2 weeks' }, + { value: '30d', label: '1M', description: 'Last month' }, + { value: '90d', label: '3M', description: 'Last 3 months' }, + { value: '1y', label: '1Y', description: 'Last year' }, + { value: 'all', label: 'All', description: 'All time' }, +]; + +/** + * Timeline Chart - Shows trends over time. + * User-friendly design with view toggles and interactive brush. + * Responds to domain/sentiment filters. + */ +export function TimelineChart({ data }: TimelineChartProps) { + const { filters, setTimeRange, setBrushRange } = useReviewIQFilters(); + const [viewMode, setViewMode] = useState('sentiment'); + const [localBrushRange, setLocalBrushRange] = useState<{ + startIndex: number; + endIndex: number; + } | null>(null); + + // Sort data chronologically (oldest first, most recent on right) + const sortedData = useMemo(() => { + return [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + }, [data]); + + // Calculate summary stats + const stats = useMemo(() => { + if (sortedData.length === 0) return null; + + const totalReviews = sortedData.reduce((sum, d) => sum + d.review_count, 0); + const totalPositive = sortedData.reduce((sum, d) => sum + d.positive_count, 0); + const totalNegative = sortedData.reduce((sum, d) => sum + d.negative_count, 0); + const avgRating = sortedData.reduce((sum, d) => sum + (d.avg_rating || 0), 0) / sortedData.length; + + // Calculate trend (compare last 30% vs first 30%) + const splitPoint = Math.floor(sortedData.length * 0.3); + const earlyData = sortedData.slice(0, splitPoint); + const recentData = sortedData.slice(-splitPoint); + + const earlyPositiveRatio = earlyData.length > 0 + ? earlyData.reduce((sum, d) => sum + d.positive_count, 0) / + Math.max(1, earlyData.reduce((sum, d) => sum + d.positive_count + d.negative_count, 0)) + : 0; + const recentPositiveRatio = recentData.length > 0 + ? recentData.reduce((sum, d) => sum + d.positive_count, 0) / + Math.max(1, recentData.reduce((sum, d) => sum + d.positive_count + d.negative_count, 0)) + : 0; + + const trendDirection = recentPositiveRatio > earlyPositiveRatio + 0.05 + ? 'improving' + : recentPositiveRatio < earlyPositiveRatio - 0.05 + ? 'declining' + : 'stable'; + + return { + totalReviews, + totalPositive, + totalNegative, + avgRating, + positiveRatio: totalReviews > 0 ? (totalPositive / (totalPositive + totalNegative)) * 100 : 0, + trendDirection, + dateRange: { + start: sortedData[0]?.date, + end: sortedData[sortedData.length - 1]?.date, + }, + }; + }, [sortedData]); + + // Handle brush change + const handleBrushChange = (range: { startIndex?: number; endIndex?: number }) => { + if ( + range && + typeof range.startIndex === 'number' && + typeof range.endIndex === 'number' + ) { + setLocalBrushRange({ startIndex: range.startIndex, endIndex: range.endIndex }); + + // Only set filter if not full range + if (range.startIndex !== 0 || range.endIndex !== sortedData.length - 1) { + const startDate = sortedData[range.startIndex]?.date; + const endDate = sortedData[range.endIndex]?.date; + if (startDate && endDate) { + setBrushRange({ start: startDate, end: endDate }); + } + } else { + setBrushRange(null); + } + } + }; + + const clearBrushRange = () => { + setLocalBrushRange({ startIndex: 0, endIndex: sortedData.length - 1 }); + setBrushRange(null); + }; + + const hasBrushFilter = filters.brushRange !== null; + const hasDomainFilter = filters.urtDomain !== null; + const hasSentimentFilter = filters.sentiment.length > 0; + const hasAnyFilter = hasBrushFilter || hasDomainFilter || hasSentimentFilter; + + // Format date for display + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Trends Over Time

+

+ {stats ? ( + <> + {stats.totalReviews.toLocaleString()} reviews + {stats.dateRange.start && stats.dateRange.end && ( + + {' '}• {formatDate(stats.dateRange.start)} - {formatDate(stats.dateRange.end)} + + )} + + ) : ( + 'No data available' + )} +

+
+
+ + {/* Controls */} +
+ {/* Active filters indicator */} + {(hasDomainFilter || hasSentimentFilter) && ( +
+ + {hasDomainFilter && DOMAIN_LABELS[filters.urtDomain!]} + {hasDomainFilter && hasSentimentFilter && ' + '} + {hasSentimentFilter && filters.sentiment.join(', ')} +
+ )} + + {/* Clear brush button */} + {hasBrushFilter && ( + + )} + + {/* Time range buttons */} +
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + {/* View Mode Toggle */} +
+ {VIEW_OPTIONS.map((opt) => ( + + ))} + + {/* Trend indicator */} + {stats && ( +
+ Trend: + {stats.trendDirection === 'improving' && ( +
+ + Improving +
+ )} + {stats.trendDirection === 'declining' && ( +
+ + Declining +
+ )} + {stats.trendDirection === 'stable' && ( +
+ + Stable +
+ )} +
+ )} +
+ + {sortedData.length === 0 ? ( +
+ + No timeline data available +
+ ) : ( + + + + + + + + + + + + + + + + + + + + + + {/* Sentiment View */} + {viewMode === 'sentiment' && ( + <> + + + + + )} + + {/* Volume View */} + {viewMode === 'volume' && ( + <> + + + + + )} + + {/* Rating View */} + {viewMode === 'rating' && ( + <> + + + + + + )} + + { + if (active && payload && payload.length) { + const data = payload[0]?.payload as TimelinePoint; + return ( +
+

+ 📅 {formatDate(String(label))} +

+ + {viewMode === 'sentiment' && ( + <> +
+ 😊 Positive + {data.positive_count} +
+
+ 😟 Negative + {data.negative_count} +
+ + )} + + {viewMode === 'volume' && ( + <> +
+ 📝 Reviews + {data.review_count} +
+
+ 💬 Mentions + {data.span_count} +
+ + )} + + {viewMode === 'rating' && ( +
+ ⭐ Avg Rating + + {data.avg_rating?.toFixed(1) || 'N/A'} + +
+ )} + +
+ {data.review_count} reviews this period +
+
+ ); + } + return null; + }} + /> + + {/* Brush for date range selection */} + +
+
+ )} + + {/* Footer hint */} +
+ 💡 Drag the handles below the chart to zoom into a specific date range +
+
+ ); +} diff --git a/web/components/reviewiq/charts/URTBarChart.tsx b/web/components/reviewiq/charts/URTBarChart.tsx new file mode 100644 index 0000000..40a15f5 --- /dev/null +++ b/web/components/reviewiq/charts/URTBarChart.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useMemo } from 'react'; +import { X, Filter, TrendingUp, TrendingDown, Minus } from 'lucide-react'; +import type { URTDomainPoint, URTDomain } from '../types'; +import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext'; + +interface URTBarChartProps { + data: URTDomainPoint[]; +} + +// User-friendly domain config with emojis and descriptions +const DOMAIN_CONFIG: Record = { + P: { emoji: '👥', label: 'Staff & Service', color: '#3b82f6', bgColor: '#dbeafe' }, + V: { emoji: '💰', label: 'Pricing & Value', color: '#ec4899', bgColor: '#fce7f3' }, + J: { emoji: '⏱️', label: 'Speed & Process', color: '#8b5cf6', bgColor: '#ede9fe' }, + O: { emoji: '🛍️', label: 'Product Quality', color: '#f97316', bgColor: '#ffedd5' }, + A: { emoji: '📍', label: 'Availability', color: '#10b981', bgColor: '#d1fae5' }, + E: { emoji: '🏢', label: 'Facilities', color: '#06b6d4', bgColor: '#cffafe' }, + R: { emoji: '🤝', label: 'Trust & Ethics', color: '#f59e0b', bgColor: '#fef3c7' }, +}; + +// Ordered domains by typical business priority +const DOMAIN_ORDER = ['P', 'V', 'J', 'O', 'A', 'E', 'R']; + +/** + * Domain Distribution - Horizontal bar chart showing what customers talk about. + * User-friendly design with emojis and clear progress bars. + * Click to filter by domain. + */ +export function URTBarChart({ data }: URTBarChartProps) { + const { filters, setURTDomain } = useReviewIQFilters(); + + // Process and sort data + const processedData = useMemo(() => { + const lookup = new Map(); + let maxCount = 0; + let totalMentions = 0; + + data.forEach((d) => { + lookup.set(d.domain, d); + if (d.count > maxCount) maxCount = d.count; + totalMentions += d.count; + }); + + // Sort by domain order, then build rows + const rows = DOMAIN_ORDER + .filter(domain => lookup.has(domain)) + .map(domain => { + const d = lookup.get(domain)!; + const config = DOMAIN_CONFIG[d.domain] || { + emoji: '📊', + label: d.domain_name || d.domain, + color: '#6b7280', + bgColor: '#f3f4f6', + }; + + const percentage = totalMentions > 0 ? (d.count / totalMentions) * 100 : 0; + const barWidth = maxCount > 0 ? (d.count / maxCount) * 100 : 0; + + // Health indicator based on positive/negative ratio + const total = d.positive_count + d.negative_count + d.neutral_count; + const positiveRatio = total > 0 ? d.positive_count / total : 0; + const negativeRatio = total > 0 ? d.negative_count / total : 0; + + let health: 'good' | 'warning' | 'critical' = 'warning'; + if (positiveRatio > 0.6) health = 'good'; + else if (negativeRatio > 0.5) health = 'critical'; + + return { + domain: d.domain, + config, + count: d.count, + reviewCount: d.review_count, + percentage, + barWidth, + health, + positiveCount: d.positive_count, + negativeCount: d.negative_count, + }; + }) + // Sort by count descending + .sort((a, b) => b.count - a.count); + + return { rows, maxCount, totalMentions }; + }, [data]); + + const handleClick = (domain: string) => { + setURTDomain(filters.urtDomain === domain ? null : domain as URTDomain); + }; + + const isFiltering = filters.urtDomain !== null; + const hasSentimentFilter = filters.sentiment.length > 0; + + return ( +
+
+
+

What Customers Talk About

+

+ {processedData.totalMentions.toLocaleString()} total mentions +

+
+
+ {hasSentimentFilter && !isFiltering && ( + + + {filters.sentiment.join(', ')} + + )} + {isFiltering && ( + <> + + {DOMAIN_CONFIG[filters.urtDomain!]?.label || filters.urtDomain} + + + + )} +
+
+ + {processedData.rows.length === 0 ? ( +
+ No data available +
+ ) : ( +
+ {processedData.rows.map((row) => { + const isActive = filters.urtDomain === row.domain; + + return ( + + ); + })} +
+ )} + + {/* Legend */} +
+ + Mostly positive + + + Mixed + + + Needs attention + +
+
+ ); +} diff --git a/web/components/reviewiq/insights/ExecutiveSummary.tsx b/web/components/reviewiq/insights/ExecutiveSummary.tsx new file mode 100644 index 0000000..1de65cc --- /dev/null +++ b/web/components/reviewiq/insights/ExecutiveSummary.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { useState } from 'react'; +import { + Sparkles, + TrendingUp, + TrendingDown, + Languages, + Loader2, + Star, + Target, + AlertTriangle, + CheckCircle2, + ChevronRight, + Zap, + Award, +} from 'lucide-react'; +import { useTranslation } from '@/hooks/useTranslation'; +import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types'; +import { getSubcodeDefinition } from '@/lib/taxonomy/data'; + +interface ExecutiveSummaryProps { + insights: Insights; + avgRating: number | null; + domainScores?: DomainScore[]; + onDriverClick?: (subcode: string) => void; + onDomainClick?: (domain: URTDomain) => void; +} + +// User-friendly domain config +const DOMAIN_CONFIG: Record = { + P: { emoji: '👥', label: 'Staff & Service' }, + V: { emoji: '💰', label: 'Pricing & Value' }, + J: { emoji: '⏱️', label: 'Speed & Process' }, + O: { emoji: '🛍️', label: 'Product Quality' }, + A: { emoji: '📍', label: 'Availability' }, + E: { emoji: '🏢', label: 'Facilities' }, + R: { emoji: '🤝', label: 'Trust & Ethics' }, +}; + +// Get rating emoji and label +const getRatingDisplay = (rating: number | null) => { + if (!rating) return { emoji: '❓', label: 'No rating', color: 'text-gray-500' }; + if (rating >= 4.5) return { emoji: '🌟', label: 'Excellent', color: 'text-green-600' }; + if (rating >= 4.0) return { emoji: '😊', label: 'Good', color: 'text-green-500' }; + if (rating >= 3.5) return { emoji: '🙂', label: 'Average', color: 'text-yellow-600' }; + if (rating >= 3.0) return { emoji: '😐', label: 'Fair', color: 'text-orange-500' }; + return { emoji: '😟', label: 'Needs Work', color: 'text-red-500' }; +}; + +// Domain complaints section +function TopComplaintsSection({ + domainScores, + weaknesses, + opportunityMatrix, + onDomainClick, +}: { + domainScores: DomainScore[]; + weaknesses: WeaknessItem[]; + opportunityMatrix: OpportunityMatrix | null; + onDomainClick?: (domain: URTDomain) => void; +}) { + const { translate, getState } = useTranslation('en'); + + // Get example quote for a domain + const getQuoteForDomain = (domainKey: string): OpportunitySpan | null => { + const weakness = weaknesses.find(w => w.domain === domainKey); + if (weakness?.example_spans?.length) { + return weakness.example_spans[0]; + } + + if (opportunityMatrix) { + const allOpportunities = [ + ...opportunityMatrix.quick_wins, + ...opportunityMatrix.critical, + ...opportunityMatrix.nice_to_have, + ...opportunityMatrix.strategic, + ]; + const opportunity = allOpportunities.find(o => o.domain === domainKey && o.spans?.length); + if (opportunity?.spans?.length) { + return opportunity.spans[0]; + } + } + + return null; + }; + + // Calculate and sort by negative percentage + const sorted = domainScores + .map(d => ({ + ...d, + negativePercent: d.total_count > 0 + ? Math.round((d.negative_count / d.total_count) * 100) + : 0, + quote: getQuoteForDomain(d.domain), + config: DOMAIN_CONFIG[d.domain] || { emoji: '📊', label: d.name }, + })) + .sort((a, b) => b.negativePercent - a.negativePercent) + .slice(0, 4); + + const handleTranslate = (e: React.MouseEvent, text: string, id: string) => { + e.stopPropagation(); + translate(text, id); + }; + + return ( +
+ {sorted.map((domain) => { + const quoteId = `domain-${domain.domain}`; + const translationState = getState(quoteId); + const displayText = translationState.isTranslated && domain.quote + ? translationState.translated + : domain.quote?.span_text; + + const severity = domain.negativePercent >= 40 ? 'critical' : + domain.negativePercent >= 25 ? 'warning' : 'ok'; + + return ( + +
+ )} +
+ + +
+ + ); + })} +
+ ); +} + +export function ExecutiveSummary({ + insights, + avgRating, + domainScores, + onDriverClick, + onDomainClick, +}: ExecutiveSummaryProps) { + const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights; + const [showFullSummary, setShowFullSummary] = useState(false); + + const topStrength = strengths[0]; + const topWeakness = weaknesses[0]; + const ratingDisplay = getRatingDisplay(avgRating); + + // Calculate potential rating improvement + const potentialRating = rating_simulator?.if_fix_top_3 || + (avgRating && topWeakness?.projected_rating_impact + ? avgRating + topWeakness.projected_rating_impact + : null); + + // If no insights, show minimal summary + if (!executive_summary && !topStrength && !topWeakness) { + return ( +
+
+
+ +
+
+

Executive Summary

+

AI-powered insights from your reviews

+
+
+
+ 📊 +
+

More data needed

+

+ Continue collecting reviews to unlock actionable insights and recommendations. +

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

Executive Summary

+

AI-powered insights from your reviews

+
+
+ + {/* Rating Badge */} + {avgRating && ( +
+
+
{ratingDisplay.emoji}
+
+
+
+ + + {avgRating.toFixed(1)} + +
+
{ratingDisplay.label}
+
+ {potentialRating && potentialRating > avgRating && ( +
+
Potential
+
+ + + {potentialRating.toFixed(1)} + +
+
+ )} +
+ )} +
+
+ + {/* AI Summary */} + {executive_summary && ( +
+
+
+ 💡 +
+

+ {executive_summary} +

+ {executive_summary.length > 150 && ( + + )} +
+
+
+
+ )} + + {/* Key Metrics Cards */} +
+
+ {/* Top Problem */} + {topWeakness && ( + + )} + + {/* Top Strength */} + {topStrength && ( + + )} +
+
+ + {/* Complaint Breakdown */} + {domainScores && domainScores.length > 0 && ( +
+
+ + Where Customers Complain Most +
+ +
+ )} + + {/* Quick Actions Footer */} +
+
+ + 💡 Click any card to drill down into details + +
+ Powered by AI + +
+
+
+
+ ); +}