Frontend: - Add Synthesis type with action plan, insights, annotations - ExecutiveSummary: Accept synthesis prop for AI narrative - SentimentPie: Accept insight prop for contextual explanation - IntensityHeatmap: Accept insight + highlightDomain props - TimelineChart: Accept insight + annotations props - All components gracefully degrade when synthesis is null Backend: - Add Stage 4: Synthesize for generating AI narratives - Gathers context from classified spans - Generates executive narrative, section insights, action plan - Produces timeline annotations and marketing angles - Stores synthesis in pipeline.executions table Components show AI insights with purple gradient styling when available, fall back to existing behavior when synthesis is not yet generated. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
446 lines
18 KiB
TypeScript
446 lines
18 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, Synthesis } 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;
|
|
// AI-generated narrative (optional - enhances when available)
|
|
synthesis?: Synthesis | null;
|
|
}
|
|
|
|
// 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 (
|
|
<button
|
|
key={domain.domain}
|
|
onClick={() => onDomainClick?.(domain.domain as URTDomain)}
|
|
className={`w-full p-3 rounded-xl border-2 transition-all hover:shadow-md text-left ${
|
|
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>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ExecutiveSummary({
|
|
insights,
|
|
avgRating,
|
|
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);
|
|
|
|
// 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>
|
|
|
|
{/* AI Summary */}
|
|
{narrativeText && (
|
|
<div className="px-6 pb-4">
|
|
<div className={`p-4 rounded-xl border ${
|
|
synthesis?.executive_narrative
|
|
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
|
|
: 'bg-white/70 border-blue-100'
|
|
}`}>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
|
|
<div className="flex-1">
|
|
{synthesis?.executive_narrative && (
|
|
<div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
|
|
)}
|
|
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
|
|
{narrativeText}
|
|
</p>
|
|
{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>
|
|
);
|
|
}
|