Files
whyrating-engine-legacy/web/components/reviewiq/AnalystReport.tsx
Alejandro Gutiérrez d5ef13b58e feat(frontend): Add BusinessReport component for 6-section €60 report
- Create BusinessReport.tsx with 6 sections:
  1. Executive Summary (health score, rating, momentum)
  2. Risk Scorecard (indicators with colors/trends)
  3. Critical Issues (evidence, solutions, timelines)
  4. Strengths to Protect (quotes, leverage actions)
  5. Action Matrix (effort/impact quadrants)
  6. 90-Day Tracking (KPI targets table)

- Update types.ts with new interfaces:
  - SynthesisV2 for new report format
  - LegacySynthesis for backwards compatibility
  - Type guard isSynthesisV2() for runtime detection

- Update ReportTab to auto-detect synthesis version
- Update AnalystReport, ReviewIQDashboard, StoryView
  for backwards compatibility with union type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:36:05 +00:00

588 lines
24 KiB
TypeScript

'use client';
import { Star, TrendingUp, TrendingDown, Minus, AlertTriangle, CheckCircle, Quote, ArrowRight, Zap, Clock, Target, Users, MessageSquare, Trophy, Megaphone } from 'lucide-react';
import type {
LegacySynthesis,
SentimentDataPoint,
URTDomainPoint,
TimelinePoint,
OverviewStats,
ReportAction,
ReportEvidence,
ReportStrength,
} from './types';
// Domain config
const DOMAINS: 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' },
};
interface AnalystReportProps {
synthesis: LegacySynthesis;
overview: OverviewStats;
sentiment: SentimentDataPoint[];
domains: URTDomainPoint[];
timeline: TimelinePoint[];
}
/**
* The Analyst Report - A consultant-quality business narrative.
* Replaces widget soup with a flowing, story-driven report.
*/
export function AnalystReport({
synthesis,
overview,
sentiment,
domains,
timeline
}: AnalystReportProps) {
const positivePct = sentiment.find(s => s.valence === 'V+')?.percentage || 0;
const negativePct = sentiment.find(s => s.valence === 'V-')?.percentage || 0;
return (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
{/* ══════════════════════════════════════════════════════════════════
THE VERDICT
══════════════════════════════════════════════════════════════════ */}
<section className="bg-gradient-to-br from-slate-900 to-slate-800 rounded-2xl p-8 text-white">
{/* Rating Display */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="text-5xl font-bold">
{synthesis.current_rating.toFixed(1)}
</div>
<div className="flex flex-col">
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-5 h-5 ${
star <= Math.round(synthesis.current_rating)
? 'fill-yellow-400 text-yellow-400'
: 'text-slate-600'
}`}
/>
))}
</div>
<span className="text-slate-400 text-sm mt-1">
{overview.total_reviews.toLocaleString()} reviews
</span>
</div>
</div>
{/* Rating Potential */}
{synthesis.rating_gap > 0 && (
<div className="text-right">
<div className="flex items-center gap-2 text-emerald-400">
<TrendingUp className="w-5 h-5" />
<span className="text-2xl font-bold">
+{synthesis.rating_gap.toFixed(1)}
</span>
</div>
<span className="text-slate-400 text-sm">
potential if fixed
</span>
</div>
)}
</div>
{/* Headline */}
<h1 className="text-2xl md:text-3xl font-bold mb-3 leading-tight">
{synthesis.headline}
</h1>
{/* Verdict */}
<p className="text-slate-300 text-lg mb-4">
{synthesis.verdict}
</p>
{/* Momentum Indicator */}
{synthesis.momentum && synthesis.momentum_detail && (
<MomentumBadge momentum={synthesis.momentum} detail={synthesis.momentum_detail} />
)}
</section>
{/* ══════════════════════════════════════════════════════════════════
THE STORY
══════════════════════════════════════════════════════════════════ */}
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-4">
Executive Summary
</h2>
<div className="prose prose-slate max-w-none">
{synthesis.narrative.split('\n\n').map((paragraph, i) => (
<p key={i} className="text-slate-700 leading-relaxed mb-4 last:mb-0">
{paragraph}
</p>
))}
</div>
</section>
{/* ══════════════════════════════════════════════════════════════════
THE DIAGNOSIS
══════════════════════════════════════════════════════════════════ */}
{synthesis.primary_problem && (
<section className="bg-red-50 rounded-2xl p-8 border border-red-100">
<div className="flex items-start gap-4">
<div className="p-3 bg-red-100 rounded-xl">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div className="flex-1">
<h2 className="text-sm font-semibold text-red-800 uppercase tracking-wide mb-2">
Primary Issue
</h2>
<p className="text-xl font-semibold text-red-900 mb-2">
{synthesis.primary_problem}
</p>
{synthesis.root_cause && (
<p className="text-red-700">
<span className="font-medium">Root cause:</span> {synthesis.root_cause}
</p>
)}
</div>
</div>
</section>
)}
{/* ══════════════════════════════════════════════════════════════════
THE STRENGTHS (What to Protect)
══════════════════════════════════════════════════════════════════ */}
{synthesis.strengths && synthesis.strengths.length > 0 && (
<StrengthsSection strengths={synthesis.strengths} />
)}
{/* ══════════════════════════════════════════════════════════════════
THE EVIDENCE
══════════════════════════════════════════════════════════════════ */}
{synthesis.evidence.length > 0 && (
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-6">
What Customers Are Saying
</h2>
<div className="grid gap-4">
{synthesis.evidence.map((item, i) => (
<EvidenceCard key={i} evidence={item} />
))}
</div>
</section>
)}
{/* ══════════════════════════════════════════════════════════════════
THE DATA (Visual Support)
══════════════════════════════════════════════════════════════════ */}
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
<div className="grid md:grid-cols-2 gap-8">
{/* Sentiment */}
<div>
<h3 className="font-semibold text-slate-900 mb-1">
{synthesis.sentiment_headline || 'Sentiment Breakdown'}
</h3>
<div className="mt-4">
<SentimentBar positive={positivePct} negative={negativePct} />
</div>
</div>
{/* Categories */}
<div>
<h3 className="font-semibold text-slate-900 mb-1">
{synthesis.category_headline || 'Category Performance'}
</h3>
<div className="mt-4 space-y-2">
{domains.slice(0, 4).map((d) => (
<DomainRow key={d.domain} domain={d} />
))}
</div>
</div>
</div>
</section>
{/* ══════════════════════════════════════════════════════════════════
THE ACTION PLAN (Enhanced)
══════════════════════════════════════════════════════════════════ */}
{synthesis.actions.length > 0 && (
<ActionPlanSection actions={synthesis.actions} ratingGap={synthesis.rating_gap} />
)}
{/* ══════════════════════════════════════════════════════════════════
FOOTER
══════════════════════════════════════════════════════════════════ */}
<footer className="text-center text-sm text-slate-400 pt-4">
Report generated {new Date(synthesis.generated_at).toLocaleDateString()}
{' · '}{synthesis.review_count} reviews · {synthesis.insight_count} insights extracted
</footer>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Momentum Badge
// ═══════════════════════════════════════════════════════════════════════════
function MomentumBadge({ momentum, detail }: { momentum: string; detail: string }) {
const config = {
improving: {
icon: <TrendingUp className="w-4 h-4" />,
bg: 'bg-emerald-500/20',
text: 'text-emerald-300',
label: 'Improving',
},
declining: {
icon: <TrendingDown className="w-4 h-4" />,
bg: 'bg-red-500/20',
text: 'text-red-300',
label: 'Declining',
},
stable: {
icon: <Minus className="w-4 h-4" />,
bg: 'bg-slate-500/20',
text: 'text-slate-300',
label: 'Stable',
},
};
const c = config[momentum as keyof typeof config] || config.stable;
return (
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full ${c.bg} ${c.text}`}>
{c.icon}
<span className="text-sm font-medium">{c.label}</span>
<span className="text-sm opacity-75">· {detail}</span>
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Strengths Section
// ═══════════════════════════════════════════════════════════════════════════
function StrengthsSection({ strengths }: { strengths: ReportStrength[] }) {
return (
<section className="bg-emerald-50 rounded-2xl p-8 border border-emerald-100">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-emerald-100 rounded-xl">
<Trophy className="w-6 h-6 text-emerald-600" />
</div>
<div>
<h2 className="text-sm font-semibold text-emerald-800 uppercase tracking-wide">
Your Strengths
</h2>
<p className="text-emerald-600 text-sm">Protect and leverage these competitive advantages</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{strengths.map((strength, i) => (
<StrengthCard key={i} strength={strength} />
))}
</div>
</section>
);
}
function StrengthCard({ strength }: { strength: ReportStrength }) {
return (
<div className="bg-white rounded-xl p-4 border border-emerald-200">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-slate-900">{strength.title}</h3>
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">
{strength.mention_count} mentions
</span>
</div>
{/* Quote */}
{strength.quote && (
<div className="flex items-start gap-2 mb-3">
<Quote className="w-4 h-4 text-emerald-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-slate-600 italic">"{strength.quote}"</p>
</div>
)}
{/* Marketing Angle */}
{strength.marketing_angle && (
<div className="flex items-start gap-2 bg-amber-50 rounded-lg px-3 py-2">
<Megaphone className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800">
<span className="font-medium">Marketing:</span> {strength.marketing_angle}
</p>
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Action Plan Section (Enhanced)
// ═══════════════════════════════════════════════════════════════════════════
function ActionPlanSection({ actions, ratingGap }: { actions: ReportAction[]; ratingGap: number }) {
// Group actions by effort/timeline
const quickWins = actions.filter(a => a.effort === 'quick_win');
const moderate = actions.filter(a => a.effort === 'moderate');
const strategic = actions.filter(a => a.effort === 'strategic');
// Calculate totals
const totalImpact = actions.reduce((sum, a) => sum + (a.impact_stars || 0), 0);
const totalComplaints = actions.reduce((sum, a) => sum + (a.complaint_count || 0), 0);
return (
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
{/* Header with Impact Summary */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-1">
Action Plan
</h2>
<p className="text-2xl font-bold">
{actions.length} actions to gain +{totalImpact.toFixed(1)}
</p>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-slate-400 text-sm">
<MessageSquare className="w-4 h-4" />
<span>Addresses {totalComplaints} complaints</span>
</div>
</div>
</div>
{/* Impact Progress Bar */}
<div className="mt-4">
<div className="flex items-center gap-3 text-sm">
<span className="text-slate-400">Potential Impact</span>
<div className="flex-1 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 rounded-full transition-all"
style={{ width: `${Math.min(100, (totalImpact / Math.max(ratingGap, 1)) * 100)}%` }}
/>
</div>
<span className="text-emerald-400 font-medium">+{totalImpact.toFixed(1)}</span>
</div>
</div>
</div>
{/* Action Groups */}
<div className="p-6 space-y-8">
{/* Quick Wins */}
{quickWins.length > 0 && (
<ActionGroup
title="Quick Wins"
subtitle="Implement this week"
icon={<Zap className="w-5 h-5" />}
iconBg="bg-amber-100 text-amber-600"
actions={quickWins}
/>
)}
{/* This Quarter */}
{moderate.length > 0 && (
<ActionGroup
title="This Quarter"
subtitle="Plan and execute over 1-3 months"
icon={<Clock className="w-5 h-5" />}
iconBg="bg-blue-100 text-blue-600"
actions={moderate}
/>
)}
{/* Strategic */}
{strategic.length > 0 && (
<ActionGroup
title="Strategic Initiatives"
subtitle="Long-term investments"
icon={<Target className="w-5 h-5" />}
iconBg="bg-purple-100 text-purple-600"
actions={strategic}
/>
)}
</div>
</section>
);
}
function ActionGroup({
title,
subtitle,
icon,
iconBg,
actions,
}: {
title: string;
subtitle: string;
icon: React.ReactNode;
iconBg: string;
actions: ReportAction[];
}) {
return (
<div>
{/* Group Header */}
<div className="flex items-center gap-3 mb-4">
<div className={`p-2 rounded-lg ${iconBg}`}>
{icon}
</div>
<div>
<h3 className="font-semibold text-slate-900">{title}</h3>
<p className="text-sm text-slate-500">{subtitle}</p>
</div>
</div>
{/* Actions */}
<div className="space-y-3 pl-12">
{actions.map((action, i) => (
<EnhancedActionCard key={i} action={action} />
))}
</div>
</div>
);
}
function EnhancedActionCard({ action }: { action: ReportAction }) {
const priorityConfig = {
critical: { bg: 'bg-red-50 border-red-200', badge: 'bg-red-100 text-red-700', label: 'Critical' },
high: { bg: 'bg-orange-50 border-orange-200', badge: 'bg-orange-100 text-orange-700', label: 'High' },
medium: { bg: 'bg-slate-50 border-slate-200', badge: 'bg-slate-100 text-slate-700', label: 'Medium' },
};
const config = priorityConfig[action.priority as keyof typeof priorityConfig] || priorityConfig.medium;
return (
<div className={`p-4 rounded-xl border ${config.bg} transition-all hover:shadow-md`}>
{/* Action Title */}
<p className="font-semibold text-slate-900 mb-3">
{action.action}
</p>
{/* Meta Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
{/* Priority */}
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${config.badge}`}>
{config.label}
</span>
</div>
{/* Owner */}
<div className="flex items-center gap-1.5 text-sm text-slate-600">
<Users className="w-3.5 h-3.5" />
<span>{action.owner}</span>
</div>
{/* Impact */}
<div className="flex items-center gap-1.5 text-sm">
<TrendingUp className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-emerald-600 font-medium">{action.impact}</span>
</div>
{/* Complaints */}
{action.complaint_count > 0 && (
<div className="flex items-center gap-1.5 text-sm text-slate-600">
<MessageSquare className="w-3.5 h-3.5" />
<span>{action.complaint_count} complaints</span>
</div>
)}
</div>
{/* Evidence Quote */}
{action.evidence && (
<div className="flex items-start gap-2 text-sm text-slate-500 italic border-l-2 border-slate-300 pl-3 mb-3">
<span>"{action.evidence}"</span>
</div>
)}
{/* Success Metric */}
{action.success_metric && (
<div className="flex items-center gap-2 text-sm bg-emerald-50 text-emerald-700 rounded-lg px-3 py-2">
<CheckCircle className="w-4 h-4 flex-shrink-0" />
<span><span className="font-medium">Success:</span> {action.success_metric}</span>
</div>
)}
</div>
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Sub-components
// ═══════════════════════════════════════════════════════════════════════════
function EvidenceCard({ evidence }: { evidence: ReportEvidence }) {
const isDamaging = evidence.sentiment === 'damaging';
return (
<div className={`p-4 rounded-xl border-l-4 ${
isDamaging
? 'bg-red-50 border-red-400'
: 'bg-emerald-50 border-emerald-400'
}`}>
<div className="flex items-start gap-3">
<Quote className={`w-5 h-5 mt-0.5 flex-shrink-0 ${
isDamaging ? 'text-red-400' : 'text-emerald-400'
}`} />
<div>
<p className={`font-medium ${isDamaging ? 'text-red-900' : 'text-emerald-900'}`}>
"{evidence.quote}"
</p>
<p className={`text-sm mt-1 ${isDamaging ? 'text-red-600' : 'text-emerald-600'}`}>
{evidence.context}
</p>
</div>
</div>
</div>
);
}
function SentimentBar({ positive, negative }: { positive: number; negative: number }) {
const neutral = Math.max(0, 100 - positive - negative);
return (
<div>
<div className="h-4 rounded-full overflow-hidden flex bg-slate-100">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${positive}%` }}
/>
<div
className="bg-slate-300 transition-all"
style={{ width: `${neutral}%` }}
/>
<div
className="bg-red-500 transition-all"
style={{ width: `${negative}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-sm">
<span className="text-emerald-600 font-medium">{positive.toFixed(0)}% positive</span>
<span className="text-red-600 font-medium">{negative.toFixed(0)}% negative</span>
</div>
</div>
);
}
function DomainRow({ domain }: { domain: URTDomainPoint }) {
const config = DOMAINS[domain.domain] || { emoji: '📊', label: domain.domain_name };
const total = domain.positive_count + domain.negative_count;
const negativePct = total > 0 ? (domain.negative_count / total) * 100 : 0;
const status = negativePct > 40 ? 'critical' : negativePct > 25 ? 'warning' : 'good';
const statusColors = {
critical: 'text-red-600',
warning: 'text-orange-600',
good: 'text-emerald-600',
};
return (
<div className="flex items-center gap-3">
<span className="text-lg">{config.emoji}</span>
<span className="flex-1 text-sm text-slate-700">{config.label}</span>
<span className={`text-sm font-medium ${statusColors[status]}`}>
{negativePct.toFixed(0)}% issues
</span>
</div>
);
}