Files
whyrating-engine-legacy/web/components/reviewiq/insights/ExecutiveSummary.tsx
2026-02-02 18:19:00 +00:00

439 lines
17 KiB
TypeScript

'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<string, { emoji: string; label: string }> = {
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 (
<div className="space-y-2">
{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 (
<div
key={domain.domain}
role="button"
tabIndex={0}
onClick={() => onDomainClick?.(domain.domain as URTDomain)}
onKeyDown={(e) => e.key === 'Enter' && onDomainClick?.(domain.domain as URTDomain)}
className={`w-full p-3 rounded-xl border-2 transition-all hover:shadow-md text-left cursor-pointer ${
severity === 'critical' ? 'bg-red-50 border-red-200 hover:border-red-300' :
severity === 'warning' ? 'bg-orange-50 border-orange-200 hover:border-orange-300' :
'bg-gray-50 border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center gap-3">
{/* Emoji */}
<span className="text-2xl">{domain.config.emoji}</span>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-gray-900">
{domain.config.label}
</span>
<span className={`text-sm font-bold ${
severity === 'critical' ? 'text-red-600' :
severity === 'warning' ? 'text-orange-600' :
'text-gray-600'
}`}>
{domain.negativePercent}% complaints
</span>
</div>
{/* Progress bar */}
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
severity === 'critical' ? 'bg-red-500' :
severity === 'warning' ? 'bg-orange-500' :
'bg-green-500'
}`}
style={{ width: `${Math.min(100, domain.negativePercent)}%` }}
/>
</div>
{/* Quote */}
{domain.quote && displayText && (
<div className="mt-2 flex items-start gap-2">
<p className="text-xs text-gray-600 italic line-clamp-1 flex-1">
"{displayText}"
</p>
<button
onClick={(e) => handleTranslate(e, domain.quote!.span_text, quoteId)}
disabled={translationState.isLoading}
className={`p-1 rounded flex-shrink-0 transition-colors ${
translationState.isTranslated
? 'bg-blue-100 hover:bg-blue-200'
: 'bg-white/50 hover:bg-white'
}`}
title={translationState.isTranslated ? 'Show original' : 'Translate'}
>
{translationState.isLoading ? (
<Loader2 className="w-3 h-3 text-blue-500 animate-spin" />
) : (
<Languages className={`w-3 h-3 ${
translationState.isTranslated ? 'text-blue-600' : 'text-gray-400'
}`} />
)}
</button>
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-400 flex-shrink-0" />
</div>
</div>
);
})}
</div>
);
}
export function ExecutiveSummary({
insights,
avgRating,
domainScores,
onDriverClick,
onDomainClick,
}: ExecutiveSummaryProps) {
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
const [showFullSummary, setShowFullSummary] = useState(false);
// Use the generated summary from insights
const narrativeText = executive_summary;
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 (
<div className="bg-gradient-to-br from-slate-50 to-blue-50 rounded-2xl border-2 border-blue-100 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-3 bg-blue-100 rounded-xl">
<Sparkles className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-gray-900">Executive Summary</h3>
<p className="text-sm text-gray-500">AI-powered insights from your reviews</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-white/60 rounded-xl border border-blue-100">
<span className="text-3xl">📊</span>
<div>
<p className="font-medium text-gray-700">More data needed</p>
<p className="text-sm text-gray-500">
Continue collecting reviews to unlock actionable insights and recommendations.
</p>
</div>
</div>
</div>
);
}
return (
<div className="bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 rounded-2xl border-2 border-blue-100 overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl shadow-lg">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-xl font-bold text-gray-900">Executive Summary</h3>
<p className="text-sm text-gray-500">AI-powered insights from your reviews</p>
</div>
</div>
{/* Rating Badge */}
{avgRating && (
<div className="flex items-center gap-3 p-3 bg-white rounded-xl shadow-sm border border-gray-100">
<div className="text-center">
<div className="text-3xl">{ratingDisplay.emoji}</div>
</div>
<div>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
<span className={`text-2xl font-bold ${ratingDisplay.color}`}>
{avgRating.toFixed(1)}
</span>
</div>
<div className="text-xs text-gray-500">{ratingDisplay.label}</div>
</div>
{potentialRating && potentialRating > avgRating && (
<div className="pl-3 border-l border-gray-200">
<div className="text-xs text-gray-500">Potential</div>
<div className="flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-green-500" />
<span className="text-lg font-bold text-green-600">
{potentialRating.toFixed(1)}
</span>
</div>
</div>
)}
</div>
)}
</div>
</div>
{/* Summary */}
{narrativeText && (
<div className="px-6 pb-4">
<div className="p-4 rounded-xl border bg-white/70 border-blue-100">
<div className="flex items-start gap-2">
<span className="text-lg">💡</span>
<div className="flex-1">
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
{narrativeText}
</p>
{narrativeText.length > 200 && (
<button
onClick={() => setShowFullSummary(!showFullSummary)}
className="text-blue-600 text-sm font-medium mt-1 hover:underline"
>
{showFullSummary ? 'Show less' : 'Read more'}
</button>
)}
</div>
</div>
</div>
</div>
)}
{/* Key Metrics Cards */}
<div className="px-6 pb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* Top Problem */}
{topWeakness && (
<button
onClick={() => onDriverClick?.(topWeakness.subcode)}
className="group p-4 bg-white rounded-xl border-2 border-red-100 hover:border-red-300 hover:shadow-lg transition-all text-left"
>
<div className="flex items-start gap-3">
<div className="p-2 bg-red-100 rounded-lg group-hover:bg-red-200 transition-colors">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-red-600 uppercase tracking-wide">
🔥 #1 Problem
</span>
</div>
<div className="font-bold text-gray-900 truncate">
{topWeakness.subcode_name}
</div>
<div className="text-xs text-gray-500 truncate mt-0.5">
{getSubcodeDefinition(topWeakness.subcode)}
</div>
<div className="flex items-center gap-3 mt-2">
<span className="text-sm font-bold text-red-600">
{topWeakness.negative_percentage.toFixed(0)}% negative
</span>
<span className="text-xs text-gray-400">
{topWeakness.span_count} mentions
</span>
</div>
</div>
{topWeakness.projected_rating_impact && (
<div className="text-right flex-shrink-0">
<div className="text-xs text-gray-500">If fixed</div>
<div className="flex items-center gap-1 text-green-600">
<Zap className="w-4 h-4" />
<span className="font-bold">
+{topWeakness.projected_rating_impact.toFixed(2)}
</span>
</div>
</div>
)}
</div>
</button>
)}
{/* Top Strength */}
{topStrength && (
<button
onClick={() => onDriverClick?.(topStrength.subcode)}
className="group p-4 bg-white rounded-xl border-2 border-green-100 hover:border-green-300 hover:shadow-lg transition-all text-left"
>
<div className="flex items-start gap-3">
<div className="p-2 bg-green-100 rounded-lg group-hover:bg-green-200 transition-colors">
<Award className="w-5 h-5 text-green-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-green-600 uppercase tracking-wide">
#1 Strength
</span>
</div>
<div className="font-bold text-gray-900 truncate">
{topStrength.subcode_name}
</div>
<div className="text-xs text-gray-500 truncate mt-0.5">
{getSubcodeDefinition(topStrength.subcode)}
</div>
<div className="flex items-center gap-3 mt-2">
<span className="text-sm font-bold text-green-600">
{topStrength.positive_percentage.toFixed(0)}% positive
</span>
<span className="text-xs text-gray-400">
{topStrength.span_count} mentions
</span>
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-xs text-gray-500">Marketing</div>
<CheckCircle2 className="w-5 h-5 text-green-500 ml-auto" />
</div>
</div>
</button>
)}
</div>
</div>
{/* Complaint Breakdown */}
{domainScores && domainScores.length > 0 && (
<div className="px-6 pb-6">
<div className="flex items-center gap-2 mb-3">
<Target className="w-4 h-4 text-gray-600" />
<span className="font-semibold text-gray-700">Where Customers Complain Most</span>
</div>
<TopComplaintsSection
domainScores={domainScores}
weaknesses={weaknesses}
opportunityMatrix={opportunity_matrix}
onDomainClick={onDomainClick}
/>
</div>
)}
{/* Quick Actions Footer */}
<div className="px-6 py-4 bg-gradient-to-r from-blue-500/5 to-indigo-500/5 border-t border-blue-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">
💡 Click any card to drill down into details
</span>
<div className="flex items-center gap-1 text-blue-600">
<span className="font-medium">Powered by AI</span>
<Sparkles className="w-4 h-4" />
</div>
</div>
</div>
</div>
);
}