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>
326 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|