feat(reviewiq): Add AI synthesis support to dashboard components
Frontend: - Add Synthesis type with action plan, insights, annotations - ExecutiveSummary: Accept synthesis prop for AI narrative - SentimentPie: Accept insight prop for contextual explanation - IntensityHeatmap: Accept insight + highlightDomain props - TimelineChart: Accept insight + annotations props - All components gracefully degrade when synthesis is null Backend: - Add Stage 4: Synthesize for generating AI narratives - Gathers context from classified spans - Generates executive narrative, section insights, action plan - Produces timeline annotations and marketing angles - Stores synthesis in pipeline.executions table Components show AI insights with purple gradient styling when available, fall back to existing behavior when synthesis is not yet generated. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
261
web/components/reviewiq/kpi/DomainScores.tsx
Normal file
261
web/components/reviewiq/kpi/DomainScores.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client';
|
||||
|
||||
import { ThumbsUp, ThumbsDown, MessageSquare, Info } from 'lucide-react';
|
||||
import type { DomainScore, URTDomain } from '../types';
|
||||
import { DOMAIN_COLORS, DOMAIN_LABELS } from '../types';
|
||||
|
||||
interface DomainScoresProps {
|
||||
scores: DomainScore[];
|
||||
overallIndex: number | null;
|
||||
onDomainClick?: (domain: URTDomain) => void;
|
||||
activeDomain?: URTDomain | null;
|
||||
}
|
||||
|
||||
// Domain descriptions explaining what each measures
|
||||
const DOMAIN_DESCRIPTIONS: Record<string, string> = {
|
||||
O: 'Product/service quality, features, and reliability',
|
||||
P: 'Staff attitude, helpfulness, and professionalism',
|
||||
J: 'Are appointments on time? Is the process smooth?',
|
||||
E: 'Physical space, ambiance, cleanliness, and safety',
|
||||
A: 'Can you get there? Location, open hours, parking',
|
||||
V: 'Pricing fairness, value for money, and billing clarity',
|
||||
R: 'Trust, consistency, care, and problem recovery',
|
||||
};
|
||||
|
||||
// Get color based on score value
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 70) return '#22c55e'; // green-500
|
||||
if (score >= 50) return '#eab308'; // yellow-500
|
||||
return '#ef4444'; // red-500
|
||||
}
|
||||
|
||||
// Get background color (lighter) based on score value
|
||||
function getScoreBgColor(score: number): string {
|
||||
if (score >= 70) return '#dcfce7'; // green-100
|
||||
if (score >= 50) return '#fef9c3'; // yellow-100
|
||||
return '#fee2e2'; // red-100
|
||||
}
|
||||
|
||||
// Get status label
|
||||
function getStatusLabel(score: number): string {
|
||||
if (score >= 70) return 'Good';
|
||||
if (score >= 50) return 'Needs Work';
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
// Get emoji for status
|
||||
function getStatusEmoji(score: number): string {
|
||||
if (score >= 70) return '✓';
|
||||
if (score >= 50) return '!';
|
||||
return '✗';
|
||||
}
|
||||
|
||||
export function DomainScores({
|
||||
scores,
|
||||
overallIndex,
|
||||
onDomainClick,
|
||||
activeDomain,
|
||||
}: DomainScoresProps) {
|
||||
// Sort scores by domain order: O, P, J, E, A, V, R
|
||||
const domainOrder = ['O', 'P', 'J', 'E', 'A', 'V', 'R'];
|
||||
const sortedScores = [...scores].sort(
|
||||
(a, b) => domainOrder.indexOf(a.domain) - domainOrder.indexOf(b.domain)
|
||||
);
|
||||
|
||||
// Calculate totals
|
||||
const totalPositive = scores.reduce((sum, s) => sum + s.positive_count, 0);
|
||||
const totalNegative = scores.reduce((sum, s) => sum + s.negative_count, 0);
|
||||
const totalMentions = scores.reduce((sum, s) => sum + s.total_count, 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-gray-200 p-5">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 mb-1">What Customers Talk About</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
How you're performing in each area of the customer experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overall Experience Index */}
|
||||
{overallIndex !== null && (
|
||||
<div
|
||||
className="flex flex-col items-center px-4 py-2 rounded-xl"
|
||||
style={{ backgroundColor: getScoreBgColor(overallIndex) }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wide text-gray-500 font-medium">
|
||||
Overall Score
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className="text-3xl font-bold"
|
||||
style={{ color: getScoreColor(overallIndex) }}
|
||||
>
|
||||
{overallIndex.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">/100</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-5 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-green-100 rounded">
|
||||
<ThumbsUp className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-green-600">{totalPositive}</div>
|
||||
<div className="text-[10px] text-gray-500">Happy comments</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-red-100 rounded">
|
||||
<ThumbsDown className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-red-600">{totalNegative}</div>
|
||||
<div className="text-[10px] text-gray-500">Complaints</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-blue-100 rounded">
|
||||
<MessageSquare className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600">{totalMentions}</div>
|
||||
<div className="text-[10px] text-gray-500">Topics analyzed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* How to read this */}
|
||||
<div className="flex items-center gap-2 mb-3 p-2 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<Info className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
<p className="text-[11px] text-blue-700">
|
||||
<strong>Score = % positive feedback.</strong> Higher is better. Based on {totalMentions.toLocaleString()} things customers mentioned in their reviews.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Domain Score Cards */}
|
||||
<div className="space-y-2">
|
||||
{sortedScores.map((score) => {
|
||||
const isActive = activeDomain === score.domain;
|
||||
const scoreColor = getScoreColor(score.score);
|
||||
const scoreBg = getScoreBgColor(score.score);
|
||||
const positiveRatio = score.total_count > 0
|
||||
? Math.round((score.positive_count / score.total_count) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={score.domain}
|
||||
onClick={() => onDomainClick?.(score.domain as URTDomain)}
|
||||
className={`
|
||||
w-full flex items-center gap-4 p-3 rounded-xl transition-all text-left
|
||||
hover:shadow-md cursor-pointer border-2
|
||||
${isActive
|
||||
? 'shadow-md border-current'
|
||||
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
borderColor: isActive ? DOMAIN_COLORS[score.domain] : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Domain Badge */}
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center text-white font-bold text-lg flex-shrink-0 shadow-sm"
|
||||
style={{ backgroundColor: DOMAIN_COLORS[score.domain] }}
|
||||
>
|
||||
{score.domain}
|
||||
</div>
|
||||
|
||||
{/* Domain Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{DOMAIN_LABELS[score.domain] || score.name}
|
||||
</span>
|
||||
<p className="text-[10px] text-gray-400 leading-tight">
|
||||
{DOMAIN_DESCRIPTIONS[score.domain]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
style={{ color: scoreColor }}
|
||||
>
|
||||
{score.score.toFixed(0)}%
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded-lg font-semibold flex items-center gap-1"
|
||||
style={{ backgroundColor: scoreBg, color: scoreColor }}
|
||||
>
|
||||
{getStatusEmoji(score.score)} {getStatusLabel(score.score)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with threshold markers */}
|
||||
<div className="relative h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
{/* Threshold markers */}
|
||||
<div className="absolute left-[50%] top-0 bottom-0 w-px bg-gray-300 z-10" />
|
||||
<div className="absolute left-[70%] top-0 bottom-0 w-px bg-gray-300 z-10" />
|
||||
|
||||
{/* Score fill */}
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 relative"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, score.score))}%`,
|
||||
backgroundColor: scoreColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metrics row */}
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<span className="text-green-600 font-medium">
|
||||
👍 {score.positive_count} positive
|
||||
</span>
|
||||
<span className="text-red-600 font-medium">
|
||||
👎 {score.negative_count} negative
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{score.total_count} total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Threshold Legend */}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-100">
|
||||
<div className="text-[10px] text-gray-400">
|
||||
Click any area to filter the dashboard
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-2 rounded bg-red-500" />
|
||||
<span className="text-gray-500"><50%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-2 rounded bg-yellow-500" />
|
||||
<span className="text-gray-500">50-69%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-2 rounded bg-green-500" />
|
||||
<span className="text-gray-500">≥70%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
web/components/reviewiq/kpi/KPICard.tsx
Normal file
63
web/components/reviewiq/kpi/KPICard.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface KPICardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: LucideIcon;
|
||||
colorClass: string;
|
||||
onClick?: () => void;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clickable KPI card component for the dashboard.
|
||||
*/
|
||||
export function KPICard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
colorClass,
|
||||
onClick,
|
||||
isActive = false,
|
||||
}: KPICardProps) {
|
||||
const baseClasses = `
|
||||
rounded-xl p-4 shadow-md hover:shadow-lg transition-all cursor-pointer
|
||||
border-2 ${colorClass}
|
||||
`;
|
||||
|
||||
const activeClasses = isActive
|
||||
? 'ring-2 ring-offset-2 ring-blue-500 scale-[1.02]'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${activeClasses}`}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-sm font-bold">{title}</span>
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className="px-2 py-0.5 bg-blue-600 text-white text-[10px] font-bold rounded-full">
|
||||
ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-3xl font-bold">{value}</div>
|
||||
{subtitle && <div className="text-xs mt-1 font-medium opacity-80">{subtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
web/components/reviewiq/kpi/KPISection.tsx
Normal file
120
web/components/reviewiq/kpi/KPISection.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Star,
|
||||
Target,
|
||||
Layers,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { KPICard } from './KPICard';
|
||||
import type { OverviewStats, Sentiment } from '../types';
|
||||
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||
|
||||
interface KPISectionProps {
|
||||
overview: OverviewStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI cards section showing overview statistics.
|
||||
* Cards are clickable to filter the dashboard.
|
||||
*/
|
||||
export function KPISection({ overview }: KPISectionProps) {
|
||||
const { filters, toggleSentiment } = useReviewIQFilters();
|
||||
|
||||
const positiveActive = filters.sentiment.includes('positive');
|
||||
const negativeActive = filters.sentiment.includes('negative');
|
||||
|
||||
const totalSentiment =
|
||||
overview.positive_count + overview.negative_count + overview.neutral_count + overview.mixed_count;
|
||||
|
||||
const positivePercent =
|
||||
totalSentiment > 0 ? ((overview.positive_count / totalSentiment) * 100).toFixed(0) : '0';
|
||||
const negativePercent =
|
||||
totalSentiment > 0 ? ((overview.negative_count / totalSentiment) * 100).toFixed(0) : '0';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* Total Reviews */}
|
||||
<KPICard
|
||||
title="Reviews"
|
||||
value={overview.total_reviews}
|
||||
subtitle="Total processed"
|
||||
icon={MessageSquare}
|
||||
colorClass="bg-gradient-to-br from-blue-100 to-blue-200 border-blue-400 text-blue-900"
|
||||
/>
|
||||
|
||||
{/* Total Spans */}
|
||||
<KPICard
|
||||
title="Spans"
|
||||
value={overview.total_spans}
|
||||
subtitle="Classified segments"
|
||||
icon={Layers}
|
||||
colorClass="bg-gradient-to-br from-purple-100 to-purple-200 border-purple-400 text-purple-900"
|
||||
/>
|
||||
|
||||
{/* Open Issues */}
|
||||
<KPICard
|
||||
title="Issues"
|
||||
value={overview.open_issues}
|
||||
subtitle="Open issues"
|
||||
icon={AlertTriangle}
|
||||
colorClass="bg-gradient-to-br from-orange-100 to-orange-200 border-orange-400 text-orange-900"
|
||||
/>
|
||||
|
||||
{/* Average Rating */}
|
||||
<KPICard
|
||||
title="Avg Rating"
|
||||
value={overview.avg_rating !== null ? `${overview.avg_rating.toFixed(1)}` : 'N/A'}
|
||||
subtitle="Star rating"
|
||||
icon={Star}
|
||||
colorClass="bg-gradient-to-br from-yellow-100 to-yellow-200 border-yellow-400 text-yellow-900"
|
||||
/>
|
||||
|
||||
{/* Positive Count */}
|
||||
<KPICard
|
||||
title="Positive"
|
||||
value={overview.positive_count}
|
||||
subtitle={`${positivePercent}% of mentions`}
|
||||
icon={ThumbsUp}
|
||||
colorClass="bg-gradient-to-br from-green-100 to-green-200 border-green-400 text-green-900"
|
||||
onClick={() => toggleSentiment('positive')}
|
||||
isActive={positiveActive}
|
||||
/>
|
||||
|
||||
{/* Negative Count */}
|
||||
<KPICard
|
||||
title="Negative"
|
||||
value={overview.negative_count}
|
||||
subtitle={`${negativePercent}% of mentions`}
|
||||
icon={ThumbsDown}
|
||||
colorClass="bg-gradient-to-br from-red-100 to-red-200 border-red-400 text-red-900"
|
||||
onClick={() => toggleSentiment('negative')}
|
||||
isActive={negativeActive}
|
||||
/>
|
||||
|
||||
{/* Neutral Count */}
|
||||
<KPICard
|
||||
title="Neutral"
|
||||
value={overview.neutral_count}
|
||||
subtitle="Neutral mentions"
|
||||
icon={Target}
|
||||
colorClass="bg-gradient-to-br from-gray-100 to-gray-200 border-gray-400 text-gray-900"
|
||||
onClick={() => toggleSentiment('neutral')}
|
||||
isActive={filters.sentiment.includes('neutral')}
|
||||
/>
|
||||
|
||||
{/* Mixed Count */}
|
||||
<KPICard
|
||||
title="Mixed"
|
||||
value={overview.mixed_count}
|
||||
subtitle="Mixed mentions"
|
||||
icon={TrendingUp}
|
||||
colorClass="bg-gradient-to-br from-amber-100 to-amber-200 border-amber-400 text-amber-900"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user