Files
Alejandro Gutiérrez c8ecb4b98f 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>
2026-01-29 02:59:47 +00:00

326 lines
14 KiB
TypeScript

'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[];
// 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
const DOMAIN_CONFIG: Record<string, {
emoji: string;
label: string;
description: string;
}> = {
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, insight, highlightDomain }: 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<string, URTDomainPoint>();
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 (
<div className={`bg-white rounded-xl p-6 shadow-md transition-all ${
hasCrossFilter
? 'border-2 border-purple-400 ring-1 ring-purple-200'
: 'border-2 border-gray-300 hover:border-blue-400'
}`}>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-900">Feedback by Category</h3>
<p className="text-sm text-gray-500 mt-1">Click any cell to filter reviews</p>
</div>
{hasCrossFilter && (
<div className="flex items-center gap-1 text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full">
<Filter className="w-3 h-3" />
<span>Filtered</span>
</div>
)}
</div>
{/* AI Insight (when available) */}
{insight && (
<div className="mb-4 p-3 bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg border border-purple-200">
<div className="flex items-start gap-2">
<span className="text-sm"></span>
<div>
<div className="text-xs font-medium text-purple-600 mb-0.5">AI Insight</div>
<p className="text-sm text-gray-700">{insight}</p>
</div>
</div>
</div>
)}
{data.length === 0 ? (
<div className="flex items-center justify-center h-64 text-gray-500">
No feedback data available
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr>
<th className="p-2 text-left text-sm font-bold text-gray-700 w-1/2">
Category
</th>
<th className="p-2 text-center text-sm font-bold text-gray-700">
<div className="flex items-center justify-center gap-1">
<ThumbsUp className="w-4 h-4 text-green-600" />
<span className="text-green-700">Praise</span>
</div>
</th>
<th className="p-2 text-center text-sm font-bold text-gray-700">
<div className="flex items-center justify-center gap-1">
<ThumbsDown className="w-4 h-4 text-red-600" />
<span className="text-red-700">Complaints</span>
</div>
</th>
<th className="p-2 text-center text-sm font-bold text-gray-500 w-16">
Health
</th>
</tr>
</thead>
<tbody>
{processedData.rows.map((row) => {
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 (
<tr
key={row.domain}
className={`border-t border-gray-100 transition-colors ${
isDomainActive ? 'bg-blue-50' :
isHighlighted ? 'bg-purple-50 ring-1 ring-purple-300' :
'hover:bg-gray-50'
}`}
>
{/* Domain Label */}
<td className="p-2">
<button
onClick={() => handleDomainClick(row.domain)}
className="flex items-center gap-2 text-left group w-full"
>
<span className="text-xl">{row.config.emoji}</span>
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
{row.config.label}
</div>
<div className="text-xs text-gray-500 truncate">
{row.config.description}
</div>
</div>
</button>
</td>
{/* Praise Cell */}
<td className="p-1.5">
<button
onClick={() => handleCellClick(row.domain, 'positive')}
className={`
w-full h-14 rounded-lg text-sm font-bold
transition-all hover:scale-105 hover:shadow-md
flex flex-col items-center justify-center
${isPositiveActive ? 'ring-2 ring-green-500 ring-offset-2' : ''}
`}
style={{
backgroundColor: getPositiveColor(row.positive, processedData.maxPositive),
color: row.positive > processedData.maxPositive * 0.5 ? 'white' : '#166534',
}}
title={`${row.positive} positive mentions (${Math.round(row.positiveRatio * 100)}%)`}
>
<span className="text-lg">{row.positive > 0 ? row.positive : '-'}</span>
{row.positive > 0 && (
<span className="text-xs opacity-75">
{Math.round(row.positiveRatio * 100)}%
</span>
)}
</button>
</td>
{/* Complaints Cell */}
<td className="p-1.5">
<button
onClick={() => handleCellClick(row.domain, 'negative')}
className={`
w-full h-14 rounded-lg text-sm font-bold
transition-all hover:scale-105 hover:shadow-md
flex flex-col items-center justify-center
${isNegativeActive ? 'ring-2 ring-red-500 ring-offset-2' : ''}
`}
style={{
backgroundColor: getNegativeColor(row.negative, processedData.maxNegative),
color: row.negative > processedData.maxNegative * 0.5 ? 'white' : '#991b1b',
}}
title={`${row.negative} negative mentions (${Math.round(row.negativeRatio * 100)}%)`}
>
<span className="text-lg">{row.negative > 0 ? row.negative : '-'}</span>
{row.negative > 0 && (
<span className="text-xs opacity-75">
{Math.round(row.negativeRatio * 100)}%
</span>
)}
</button>
</td>
{/* Health Indicator */}
<td className="p-2 text-center">
<div className="flex items-center justify-center">
{row.trend === 'up' && (
<div className="flex items-center gap-1 text-green-600" title="Mostly positive feedback">
<TrendingUp className="w-5 h-5" />
</div>
)}
{row.trend === 'down' && (
<div className="flex items-center gap-1 text-red-600" title="Needs attention">
<TrendingDown className="w-5 h-5" />
</div>
)}
{row.trend === 'neutral' && (
<div className="flex items-center gap-1 text-gray-400" title="Mixed feedback">
<Minus className="w-5 h-5" />
</div>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Legend */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-100">
<div className="flex items-center gap-4 text-xs text-gray-600">
<div className="flex items-center gap-2">
<span className="font-medium">Praise scale:</span>
<div className="flex gap-0.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#dcfce7' }} />
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#86efac' }} />
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#22c55e' }} />
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#15803d' }} />
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Complaints scale:</span>
<div className="flex gap-0.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#fee2e2' }} />
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#fca5a5' }} />
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#ef4444' }} />
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#b91c1c' }} />
</div>
</div>
</div>
<div className="text-xs text-gray-500">
<TrendingDown className="w-3 h-3 inline text-red-500" /> = needs attention
</div>
</div>
</div>
)}
</div>
);
}