- 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>
588 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|