- 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>
650 lines
25 KiB
TypeScript
650 lines
25 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
Star,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Minus,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
Quote,
|
|
Zap,
|
|
Clock,
|
|
Target,
|
|
Users,
|
|
Trophy,
|
|
Megaphone,
|
|
AlertCircle,
|
|
Calendar,
|
|
DollarSign,
|
|
Shield,
|
|
Activity,
|
|
BarChart3,
|
|
} from 'lucide-react';
|
|
import type {
|
|
SynthesisV2,
|
|
ExecutiveSummary,
|
|
RiskScorecard,
|
|
RiskIndicator,
|
|
CriticalIssue,
|
|
StrengthToProtect,
|
|
ActionMatrixItem,
|
|
TrackingKPI,
|
|
} from './types';
|
|
|
|
interface BusinessReportProps {
|
|
synthesis: SynthesisV2;
|
|
}
|
|
|
|
/**
|
|
* The €60 Business Reputation Report - 6-Section Format
|
|
* A productized, high-value report for SMB owners.
|
|
*/
|
|
export function BusinessReport({ synthesis }: BusinessReportProps) {
|
|
const { executive_summary, risk_scorecard, critical_issues, strengths, action_matrix, tracking_kpis } = synthesis;
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
|
{/* Report Header */}
|
|
<header className="text-center py-4">
|
|
<h1 className="text-2xl font-bold text-slate-900">{synthesis.report_title}</h1>
|
|
<p className="text-slate-500">{synthesis.report_date} · {synthesis.analysis_period}</p>
|
|
</header>
|
|
|
|
{/* Section 1: Executive Summary */}
|
|
<ExecutiveSummarySection summary={executive_summary} reviewCount={synthesis.review_count} />
|
|
|
|
{/* Section 2: Risk Scorecard */}
|
|
<RiskScorecardSection scorecard={risk_scorecard} />
|
|
|
|
{/* Section 3: Critical Issues */}
|
|
{critical_issues.length > 0 && (
|
|
<CriticalIssuesSection issues={critical_issues} />
|
|
)}
|
|
|
|
{/* Section 4: Strengths to Protect */}
|
|
{strengths.length > 0 && (
|
|
<StrengthsSection strengths={strengths} />
|
|
)}
|
|
|
|
{/* Section 5: Action Matrix */}
|
|
{action_matrix.length > 0 && (
|
|
<ActionMatrixSection actions={action_matrix} />
|
|
)}
|
|
|
|
{/* Section 6: 90-Day Tracking */}
|
|
{tracking_kpis.length > 0 && (
|
|
<TrackingSection kpis={tracking_kpis} />
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<footer className="text-center text-sm text-slate-400 pt-4 border-t border-slate-200">
|
|
<p>Report generated {new Date(synthesis.generated_at).toLocaleDateString()}</p>
|
|
<p>{synthesis.review_count} reviews · {synthesis.insight_count} insights analyzed</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Section 1: Executive Summary
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function ExecutiveSummarySection({ summary, reviewCount }: { summary: ExecutiveSummary; reviewCount: number }) {
|
|
const healthColor = summary.health_score >= 70 ? 'emerald' : summary.health_score >= 50 ? 'amber' : 'red';
|
|
|
|
return (
|
|
<section className="bg-gradient-to-br from-slate-900 to-slate-800 rounded-2xl p-8 text-white">
|
|
<div className="flex items-start justify-between mb-6">
|
|
{/* Health Score Gauge */}
|
|
<div className="flex items-center gap-6">
|
|
<div className="relative">
|
|
<svg className="w-24 h-24 transform -rotate-90">
|
|
<circle
|
|
cx="48"
|
|
cy="48"
|
|
r="40"
|
|
stroke="currentColor"
|
|
strokeWidth="8"
|
|
fill="none"
|
|
className="text-slate-700"
|
|
/>
|
|
<circle
|
|
cx="48"
|
|
cy="48"
|
|
r="40"
|
|
stroke="currentColor"
|
|
strokeWidth="8"
|
|
fill="none"
|
|
strokeDasharray={`${(summary.health_score / 100) * 251.2} 251.2`}
|
|
className={`text-${healthColor}-400`}
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<span className="text-2xl font-bold">{summary.health_score}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className={`inline-block px-3 py-1 rounded-full text-sm font-medium bg-${healthColor}-500/20 text-${healthColor}-300 mb-2`}>
|
|
{summary.health_label}
|
|
</div>
|
|
<div className="text-slate-400 text-sm">{reviewCount.toLocaleString()} reviews analyzed</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rating Display */}
|
|
<div className="text-right">
|
|
<div className="flex items-center justify-end gap-2 mb-1">
|
|
<span className="text-4xl font-bold">{summary.current_rating.toFixed(1)}</span>
|
|
<div className="flex">
|
|
{[1, 2, 3, 4, 5].map((star) => (
|
|
<Star
|
|
key={star}
|
|
className={`w-5 h-5 ${
|
|
star <= Math.round(summary.current_rating)
|
|
? 'fill-yellow-400 text-yellow-400'
|
|
: 'text-slate-600'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{summary.rating_gap > 0 && (
|
|
<div className="flex items-center justify-end gap-2 text-emerald-400">
|
|
<TrendingUp className="w-4 h-4" />
|
|
<span className="font-medium">→ {summary.potential_rating.toFixed(1)}★ potential</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* One-liner */}
|
|
<h2 className="text-xl font-semibold mb-3">{summary.one_liner}</h2>
|
|
|
|
{/* Key Insight */}
|
|
<p className="text-slate-300 mb-4">{summary.key_insight}</p>
|
|
|
|
{/* Revenue at Risk + Momentum */}
|
|
<div className="flex items-center justify-between pt-4 border-t border-slate-700">
|
|
<div className="flex items-center gap-2 text-red-400">
|
|
<DollarSign className="w-4 h-4" />
|
|
<span className="font-medium">{summary.estimated_revenue_at_risk} at risk</span>
|
|
</div>
|
|
<MomentumBadge momentum={summary.momentum} detail={summary.momentum_detail} />
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Section 2: Risk Scorecard
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function RiskScorecardSection({ scorecard }: { scorecard: RiskScorecard }) {
|
|
const riskColors = {
|
|
low: 'emerald',
|
|
medium: 'amber',
|
|
high: 'orange',
|
|
critical: 'red',
|
|
};
|
|
const riskColor = riskColors[scorecard.overall_risk] || 'amber';
|
|
|
|
return (
|
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-3 bg-slate-100 rounded-xl">
|
|
<Shield className="w-6 h-6 text-slate-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900">Risk Scorecard</h2>
|
|
<p className="text-sm text-slate-500">Health indicators by area</p>
|
|
</div>
|
|
</div>
|
|
<div className={`px-4 py-2 rounded-full bg-${riskColor}-100 text-${riskColor}-700 font-semibold text-sm uppercase`}>
|
|
{scorecard.overall_risk} Risk
|
|
</div>
|
|
</div>
|
|
|
|
{/* Risk Indicators Grid */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
{scorecard.indicators.map((indicator, i) => (
|
|
<RiskIndicatorCard key={i} indicator={indicator} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Immediate Attention */}
|
|
{scorecard.immediate_attention && (
|
|
<div className="flex items-start gap-3 bg-red-50 rounded-xl p-4 border border-red-100">
|
|
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="font-medium text-red-900">Immediate Attention Required</p>
|
|
<p className="text-sm text-red-700">{scorecard.immediate_attention}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RiskIndicatorCard({ indicator }: { indicator: RiskIndicator }) {
|
|
const colorMap = {
|
|
green: { bg: 'bg-emerald-50', border: 'border-emerald-200', text: 'text-emerald-700', bar: 'bg-emerald-500' },
|
|
yellow: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', bar: 'bg-amber-500' },
|
|
red: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', bar: 'bg-red-500' },
|
|
};
|
|
const colors = colorMap[indicator.color] || colorMap.yellow;
|
|
|
|
const TrendIcon = indicator.trend === 'improving' ? TrendingUp : indicator.trend === 'declining' ? TrendingDown : Minus;
|
|
|
|
return (
|
|
<div className={`${colors.bg} ${colors.border} border rounded-xl p-4`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-slate-700">{indicator.name}</span>
|
|
<TrendIcon className={`w-4 h-4 ${colors.text}`} />
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<span className={`text-2xl font-bold ${colors.text}`}>{indicator.score}</span>
|
|
<span className="text-slate-400 text-sm mb-1">/10</span>
|
|
</div>
|
|
<div className="mt-2 h-1.5 bg-slate-200 rounded-full overflow-hidden">
|
|
<div className={`h-full ${colors.bar} rounded-full`} style={{ width: `${indicator.score * 10}%` }} />
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-2">{indicator.complaint_count} complaints</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Section 3: Critical Issues
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function CriticalIssuesSection({ issues }: { issues: CriticalIssue[] }) {
|
|
return (
|
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="p-3 bg-red-100 rounded-xl">
|
|
<AlertTriangle className="w-6 h-6 text-red-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900">Critical Issues</h2>
|
|
<p className="text-sm text-slate-500">Top problems requiring immediate action</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{issues.map((issue) => (
|
|
<CriticalIssueCard key={issue.rank} issue={issue} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CriticalIssueCard({ issue }: { issue: CriticalIssue }) {
|
|
const effortColors = {
|
|
quick_win: 'bg-emerald-100 text-emerald-700',
|
|
moderate: 'bg-blue-100 text-blue-700',
|
|
strategic: 'bg-purple-100 text-purple-700',
|
|
};
|
|
|
|
return (
|
|
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
|
{/* Header */}
|
|
<div className="bg-slate-50 px-6 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center font-bold">
|
|
{issue.rank}
|
|
</span>
|
|
<div>
|
|
<h3 className="font-semibold text-slate-900">{issue.title}</h3>
|
|
<p className="text-sm text-slate-500">{issue.urt_code} · {issue.complaint_count} complaints</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-red-600 font-semibold">{issue.revenue_impact}</p>
|
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${effortColors[issue.effort] || effortColors.moderate}`}>
|
|
{issue.effort.replace('_', ' ')} · {issue.timeline}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-6 py-4 space-y-4">
|
|
{/* Root Cause */}
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-500 mb-1">Root Cause</p>
|
|
<p className="text-slate-700">{issue.root_cause}</p>
|
|
</div>
|
|
|
|
{/* Evidence */}
|
|
{issue.evidence.length > 0 && (
|
|
<div>
|
|
<p className="text-sm font-medium text-slate-500 mb-2">Customer Evidence</p>
|
|
<div className="space-y-2">
|
|
{issue.evidence.slice(0, 2).map((quote, i) => (
|
|
<div key={i} className="flex items-start gap-2 bg-red-50 rounded-lg px-3 py-2">
|
|
<Quote className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
|
<p className="text-sm text-red-800 italic">"{quote}"</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Solution */}
|
|
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-100">
|
|
<div className="flex items-start gap-2">
|
|
<CheckCircle className="w-5 h-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<p className="font-medium text-emerald-900">Recommended Solution</p>
|
|
<p className="text-sm text-emerald-700 mt-1">{issue.solution}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Section 4: Strengths to Protect
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function StrengthsSection({ strengths }: { strengths: StrengthToProtect[] }) {
|
|
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-lg font-semibold text-emerald-900">Protect Your Strengths</h2>
|
|
<p className="text-sm text-emerald-600">Competitive advantages to leverage</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: StrengthToProtect }) {
|
|
return (
|
|
<div className="bg-white rounded-xl p-5 border border-emerald-200">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-semibold text-slate-900">{strength.title}</h3>
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">
|
|
{strength.mention_count} mentions
|
|
</span>
|
|
<span className="text-emerald-600 font-medium">{strength.percentage.toFixed(0)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quotes */}
|
|
{strength.top_quotes.length > 0 && (
|
|
<div className="space-y-2 mb-4">
|
|
{strength.top_quotes.slice(0, 2).map((quote, i) => (
|
|
<div key={i} className="flex items-start gap-2">
|
|
<Quote className="w-4 h-4 text-emerald-400 mt-0.5 flex-shrink-0" />
|
|
<p className="text-sm text-slate-600 italic">"{quote.slice(0, 100)}{quote.length > 100 ? '...' : ''}"</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Risk of Loss */}
|
|
{strength.risk_of_loss && (
|
|
<div className="flex items-start gap-2 text-sm text-amber-700 bg-amber-50 rounded-lg px-3 py-2 mb-3">
|
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
<span>{strength.risk_of_loss}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Leverage Action */}
|
|
{strength.leverage_action && (
|
|
<div className="flex items-start gap-2 bg-blue-50 rounded-lg px-3 py-2">
|
|
<Megaphone className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
|
<p className="text-sm text-blue-800">
|
|
<span className="font-medium">Leverage:</span> {strength.leverage_action}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Section 5: Action Matrix
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function ActionMatrixSection({ actions }: { actions: ActionMatrixItem[] }) {
|
|
const quickWins = actions.filter(a => a.quadrant === 'quick_win');
|
|
const majorProjects = actions.filter(a => a.quadrant === 'major_project');
|
|
const others = actions.filter(a => !['quick_win', 'major_project'].includes(a.quadrant));
|
|
|
|
return (
|
|
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-white/10 rounded-lg">
|
|
<BarChart3 className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold">Action Matrix</h2>
|
|
<p className="text-slate-400 text-sm">{actions.length} prioritized actions</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-8">
|
|
{/* Quick Wins */}
|
|
{quickWins.length > 0 && (
|
|
<ActionGroup
|
|
title="Quick Wins"
|
|
subtitle="Low effort, high impact - do these first"
|
|
icon={<Zap className="w-5 h-5" />}
|
|
iconBg="bg-amber-100 text-amber-600"
|
|
actions={quickWins}
|
|
/>
|
|
)}
|
|
|
|
{/* Major Projects */}
|
|
{majorProjects.length > 0 && (
|
|
<ActionGroup
|
|
title="Major Projects"
|
|
subtitle="High effort, high impact - plan carefully"
|
|
icon={<Target className="w-5 h-5" />}
|
|
iconBg="bg-blue-100 text-blue-600"
|
|
actions={majorProjects}
|
|
/>
|
|
)}
|
|
|
|
{/* Others */}
|
|
{others.length > 0 && (
|
|
<ActionGroup
|
|
title="Other Actions"
|
|
subtitle="Consider as resources allow"
|
|
icon={<Clock className="w-5 h-5" />}
|
|
iconBg="bg-slate-100 text-slate-600"
|
|
actions={others}
|
|
/>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ActionGroup({
|
|
title,
|
|
subtitle,
|
|
icon,
|
|
iconBg,
|
|
actions,
|
|
}: {
|
|
title: string;
|
|
subtitle: string;
|
|
icon: React.ReactNode;
|
|
iconBg: string;
|
|
actions: ActionMatrixItem[];
|
|
}) {
|
|
return (
|
|
<div>
|
|
<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>
|
|
<div className="space-y-3 pl-12">
|
|
{actions.map((action, i) => (
|
|
<ActionCard key={i} action={action} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionCard({ action }: { action: ActionMatrixItem }) {
|
|
return (
|
|
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 hover:shadow-md transition-all">
|
|
<p className="font-medium text-slate-900 mb-3">{action.action}</p>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
|
<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>
|
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
<span>{action.deadline}</span>
|
|
</div>
|
|
<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.expected_lift}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
|
<Activity className="w-3.5 h-3.5" />
|
|
<span>{action.effort} effort</span>
|
|
</div>
|
|
</div>
|
|
|
|
{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>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Section 6: 90-Day Tracking
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
function TrackingSection({ kpis }: { kpis: TrackingKPI[] }) {
|
|
return (
|
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="p-3 bg-purple-100 rounded-xl">
|
|
<Calendar className="w-6 h-6 text-purple-600" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-slate-900">90-Day Tracking Framework</h2>
|
|
<p className="text-sm text-slate-500">Monitor these KPIs monthly</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-slate-200">
|
|
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-600">Metric</th>
|
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">Current</th>
|
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">30-Day</th>
|
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">60-Day</th>
|
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">90-Day</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{kpis.map((kpi, i) => (
|
|
<tr key={i} className="border-b border-slate-100 last:border-0">
|
|
<td className="py-4 px-4">
|
|
<p className="font-medium text-slate-900">{kpi.metric}</p>
|
|
<p className="text-xs text-slate-500">{kpi.measurement}</p>
|
|
</td>
|
|
<td className="text-center py-4 px-4">
|
|
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-sm font-medium">
|
|
{kpi.current_value}
|
|
</span>
|
|
</td>
|
|
<td className="text-center py-4 px-4">
|
|
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium">
|
|
{kpi.target_30_day}
|
|
</span>
|
|
</td>
|
|
<td className="text-center py-4 px-4">
|
|
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
|
|
{kpi.target_60_day}
|
|
</span>
|
|
</td>
|
|
<td className="text-center py-4 px-4">
|
|
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">
|
|
{kpi.target_90_day}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Shared Components
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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>
|
|
{detail && <span className="text-sm opacity-75">· {detail}</span>}
|
|
</div>
|
|
);
|
|
}
|