Files
whyrating-engine-legacy/web/components/reviewiq/charts/URTBarChart.tsx
Alejandro Gutiérrez 8f9dd136cd feat(reviewiq): Redesign dashboard with user-friendly UX
- ExecutiveSummary: Add rating badge with emoji, AI narrative section,
  #1 Problem/#1 Strength cards, domain complaints with progress bars
- SentimentPie: Replace pie chart with card-based design showing
  sentiment score, emoji indicators (😊😟😐🤔), percentages
- IntensityHeatmap: Transform to Praise vs Complaints heatmap with
  friendly domain labels (👥 Staff, 💰 Pricing, etc.)
- URTBarChart: Horizontal progress bars with emojis, health indicators
- TimelineChart: Add view toggles (Sentiment/Volume/Rating), trend
  indicator, fix chronological order (oldest→newest left→right)
- ReviewIQDashboard: Streamline from 11 sections to 5, remove redundancy

Removed redundant components:
- DomainScores (merged into ExecutiveSummary)
- KPISection (stats in header)
- RatingSimulator (in ExecutiveSummary)
- StrengthsWeaknesses (in ExecutiveSummary)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 02:52:13 +00:00

239 lines
8.8 KiB
TypeScript

'use client';
import { useMemo } from 'react';
import { X, Filter, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import type { URTDomainPoint, URTDomain } from '../types';
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
interface URTBarChartProps {
data: URTDomainPoint[];
}
// User-friendly domain config with emojis and descriptions
const DOMAIN_CONFIG: Record<string, {
emoji: string;
label: string;
color: string;
bgColor: string;
}> = {
P: { emoji: '👥', label: 'Staff & Service', color: '#3b82f6', bgColor: '#dbeafe' },
V: { emoji: '💰', label: 'Pricing & Value', color: '#ec4899', bgColor: '#fce7f3' },
J: { emoji: '⏱️', label: 'Speed & Process', color: '#8b5cf6', bgColor: '#ede9fe' },
O: { emoji: '🛍️', label: 'Product Quality', color: '#f97316', bgColor: '#ffedd5' },
A: { emoji: '📍', label: 'Availability', color: '#10b981', bgColor: '#d1fae5' },
E: { emoji: '🏢', label: 'Facilities', color: '#06b6d4', bgColor: '#cffafe' },
R: { emoji: '🤝', label: 'Trust & Ethics', color: '#f59e0b', bgColor: '#fef3c7' },
};
// Ordered domains by typical business priority
const DOMAIN_ORDER = ['P', 'V', 'J', 'O', 'A', 'E', 'R'];
/**
* Domain Distribution - Horizontal bar chart showing what customers talk about.
* User-friendly design with emojis and clear progress bars.
* Click to filter by domain.
*/
export function URTBarChart({ data }: URTBarChartProps) {
const { filters, setURTDomain } = useReviewIQFilters();
// Process and sort data
const processedData = useMemo(() => {
const lookup = new Map<string, URTDomainPoint>();
let maxCount = 0;
let totalMentions = 0;
data.forEach((d) => {
lookup.set(d.domain, d);
if (d.count > maxCount) maxCount = d.count;
totalMentions += d.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 config = DOMAIN_CONFIG[d.domain] || {
emoji: '📊',
label: d.domain_name || d.domain,
color: '#6b7280',
bgColor: '#f3f4f6',
};
const percentage = totalMentions > 0 ? (d.count / totalMentions) * 100 : 0;
const barWidth = maxCount > 0 ? (d.count / maxCount) * 100 : 0;
// Health indicator based on positive/negative ratio
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;
let health: 'good' | 'warning' | 'critical' = 'warning';
if (positiveRatio > 0.6) health = 'good';
else if (negativeRatio > 0.5) health = 'critical';
return {
domain: d.domain,
config,
count: d.count,
reviewCount: d.review_count,
percentage,
barWidth,
health,
positiveCount: d.positive_count,
negativeCount: d.negative_count,
};
})
// Sort by count descending
.sort((a, b) => b.count - a.count);
return { rows, maxCount, totalMentions };
}, [data]);
const handleClick = (domain: string) => {
setURTDomain(filters.urtDomain === domain ? null : domain as URTDomain);
};
const isFiltering = filters.urtDomain !== null;
const hasSentimentFilter = filters.sentiment.length > 0;
return (
<div
className={`bg-white rounded-xl p-6 shadow-md transition-all ${
isFiltering
? 'border-3 border-blue-500 ring-2 ring-blue-200'
: hasSentimentFilter
? '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">What Customers Talk About</h3>
<p className="text-sm text-gray-500 mt-1">
{processedData.totalMentions.toLocaleString()} total mentions
</p>
</div>
<div className="flex items-center gap-2">
{hasSentimentFilter && !isFiltering && (
<span 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" />
{filters.sentiment.join(', ')}
</span>
)}
{isFiltering && (
<>
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-bold rounded-full">
{DOMAIN_CONFIG[filters.urtDomain!]?.label || filters.urtDomain}
</span>
<button
onClick={() => setURTDomain(null)}
className="p-1 hover:bg-gray-200 rounded-full transition-colors"
title="Clear filter"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</>
)}
</div>
</div>
{processedData.rows.length === 0 ? (
<div className="flex items-center justify-center h-64 text-gray-500">
No data available
</div>
) : (
<div className="space-y-3">
{processedData.rows.map((row) => {
const isActive = filters.urtDomain === row.domain;
return (
<button
key={row.domain}
onClick={() => handleClick(row.domain)}
className={`w-full text-left transition-all rounded-lg p-3 ${
isActive
? 'bg-blue-50 ring-2 ring-blue-500'
: 'hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
{/* Emoji */}
<span className="text-2xl flex-shrink-0">{row.config.emoji}</span>
{/* Label and Bar */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className={`font-semibold ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
{row.config.label}
</span>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-700">
{row.count.toLocaleString()}
</span>
<span className="text-xs text-gray-500">
({row.percentage.toFixed(0)}%)
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${row.barWidth}%`,
backgroundColor: isActive ? '#3b82f6' : row.config.color,
}}
/>
</div>
{/* Sentiment mini-stats */}
<div className="flex items-center gap-3 mt-1.5 text-xs">
<span className="text-green-600 font-medium">
👍 {row.positiveCount}
</span>
<span className="text-red-600 font-medium">
👎 {row.negativeCount}
</span>
<span className="text-gray-500">
{row.reviewCount} reviews
</span>
</div>
</div>
{/* Health Indicator */}
<div className="flex-shrink-0">
{row.health === 'good' && (
<TrendingUp className="w-5 h-5 text-green-500" />
)}
{row.health === 'critical' && (
<TrendingDown className="w-5 h-5 text-red-500" />
)}
{row.health === 'warning' && (
<Minus className="w-5 h-5 text-gray-400" />
)}
</div>
</div>
</button>
);
})}
</div>
)}
{/* Legend */}
<div className="flex items-center justify-center gap-4 mt-4 pt-3 border-t border-gray-100 text-xs text-gray-500">
<span className="flex items-center gap-1">
<TrendingUp className="w-3 h-3 text-green-500" /> Mostly positive
</span>
<span className="flex items-center gap-1">
<Minus className="w-3 h-3 text-gray-400" /> Mixed
</span>
<span className="flex items-center gap-1">
<TrendingDown className="w-3 h-3 text-red-500" /> Needs attention
</span>
</div>
</div>
);
}