feat: engagement page, engagement badge on client detail, stats API integration

- New Engagement page with score cards, distribution summary, sort/filter controls
- Score breakdown bars (recency, interactions, emails, events, notes)
- Engagement badge widget on ClientDetailPage with click-to-expand details
- Added Engagement to sidebar navigation with Zap icon
- API client methods for engagement scoring and stats overview
This commit is contained in:
2026-01-30 03:41:32 +00:00
parent 7a956aebec
commit 93f127f5e9
6 changed files with 394 additions and 4 deletions

View File

@@ -18,6 +18,7 @@ import EmailComposeModal from '@/components/EmailComposeModal';
import ClientNotes from '@/components/ClientNotes';
import LogInteractionModal from '@/components/LogInteractionModal';
import MeetingPrepModal from '@/components/MeetingPrepModal';
import EngagementBadge from '@/components/EngagementBadge';
import type { Interaction } from '@/types';
export default function ClientDetailPage() {
@@ -91,9 +92,12 @@ export default function ClientDetailPage() {
{getInitials(client.firstName, client.lastName)}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{client.firstName} {client.lastName}
</h1>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{client.firstName} {client.lastName}
</h1>
<EngagementBadge clientId={client.id} />
</div>
{client.company && (
<p className="text-slate-500 dark:text-slate-400">
{client.role ? `${client.role} at ` : ''}{client.company}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { api, type EngagementScore, type EngagementResponse } from '@/lib/api';
import { Link } from 'react-router-dom';
const scoreColor = (score: number) => {
if (score >= 80) return 'text-emerald-400';
if (score >= 60) return 'text-blue-400';
if (score >= 40) return 'text-yellow-400';
if (score >= 20) return 'text-orange-400';
return 'text-red-400';
};
const scoreBg = (score: number) => {
if (score >= 80) return 'bg-emerald-500/20 border-emerald-500/40';
if (score >= 60) return 'bg-blue-500/20 border-blue-500/40';
if (score >= 40) return 'bg-yellow-500/20 border-yellow-500/40';
if (score >= 20) return 'bg-orange-500/20 border-orange-500/40';
return 'bg-red-500/20 border-red-500/40';
};
const trendIcon = (trend: string) => {
if (trend === 'rising') return '📈';
if (trend === 'declining') return '📉';
return '➡️';
};
const labelText = (score: number) => {
if (score >= 80) return 'Highly Engaged';
if (score >= 60) return 'Engaged';
if (score >= 40) return 'Warm';
if (score >= 20) return 'Cooling';
return 'Cold';
};
function ScoreBar({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
const pct = Math.round((value / max) * 100);
return (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 text-gray-400 dark:text-gray-500">{label}</span>
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="w-8 text-right text-gray-500">{value}/{max}</span>
</div>
);
}
export default function EngagementPage() {
const [data, setData] = useState<EngagementResponse | null>(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<'score' | 'name' | 'trend'>('score');
useEffect(() => {
api.getEngagementScores()
.then(setData)
.catch(console.error)
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="p-6 text-gray-400">Loading engagement scores...</div>;
if (!data) return <div className="p-6 text-red-400">Failed to load engagement data</div>;
const { scores, summary } = data;
const filtered = filter === 'all'
? scores
: scores.filter(s => {
if (filter === 'highly_engaged') return s.score >= 80;
if (filter === 'engaged') return s.score >= 60 && s.score < 80;
if (filter === 'warm') return s.score >= 40 && s.score < 60;
if (filter === 'cooling') return s.score >= 20 && s.score < 40;
if (filter === 'cold') return s.score < 20;
return true;
});
const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'score') return b.score - a.score;
if (sortBy === 'name') return a.clientName.localeCompare(b.clientName);
// trend: rising first, then stable, then declining
const trendOrder = { rising: 0, stable: 1, declining: 2 };
return (trendOrder[a.trend] || 1) - (trendOrder[b.trend] || 1);
});
return (
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Engagement Scores</h1>
<span className="text-sm text-gray-500">
Avg: <span className={`font-bold ${scoreColor(summary.averageScore)}`}>{summary.averageScore}</span> / 100
</span>
</div>
{/* Distribution summary */}
<div className="grid grid-cols-5 gap-3">
{(['highly_engaged', 'engaged', 'warm', 'cooling', 'cold'] as const).map(level => {
const labels: Record<string, string> = {
highly_engaged: '🔥 Highly Engaged',
engaged: '💚 Engaged',
warm: '🟡 Warm',
cooling: '🟠 Cooling',
cold: '❄️ Cold',
};
const count = summary.distribution[level] || 0;
return (
<button
key={level}
onClick={() => setFilter(filter === level ? 'all' : level)}
className={`p-3 rounded-lg border text-center transition-all ${
filter === level
? 'bg-indigo-500/20 border-indigo-500'
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-400'
}`}
>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{count}</div>
<div className="text-xs text-gray-500 mt-1">{labels[level]}</div>
</button>
);
})}
</div>
{/* Sort controls */}
<div className="flex gap-2 items-center text-sm">
<span className="text-gray-500">Sort by:</span>
{(['score', 'name', 'trend'] as const).map(s => (
<button
key={s}
onClick={() => setSortBy(s)}
className={`px-3 py-1 rounded-full ${
sortBy === s
? 'bg-indigo-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
{s.charAt(0).toUpperCase() + s.slice(1)}
</button>
))}
{filter !== 'all' && (
<button
onClick={() => setFilter('all')}
className="ml-auto text-gray-400 hover:text-gray-600 text-xs"
>
Clear filter
</button>
)}
</div>
{/* Client engagement cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{sorted.map(client => (
<Link
key={client.clientId}
to={`/clients/${client.clientId}`}
className={`block p-4 rounded-xl border transition-all hover:shadow-md ${scoreBg(client.score)}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{client.clientName}
</div>
<div className="text-xs text-gray-500 capitalize">{client.stage}</div>
</div>
<div className="text-right">
<div className={`text-2xl font-bold ${scoreColor(client.score)}`}>
{client.score}
</div>
<div className="text-xs text-gray-500">
{trendIcon(client.trend)} {labelText(client.score)}
</div>
</div>
</div>
<div className="space-y-1.5">
<ScoreBar label="Recency" value={client.breakdown.recency} max={40} color="bg-emerald-500" />
<ScoreBar label="Interactions" value={client.breakdown.interactions} max={25} color="bg-blue-500" />
<ScoreBar label="Emails" value={client.breakdown.emails} max={15} color="bg-purple-500" />
<ScoreBar label="Events" value={client.breakdown.events} max={10} color="bg-amber-500" />
<ScoreBar label="Notes" value={client.breakdown.notes} max={10} color="bg-pink-500" />
</div>
{client.lastContactedAt && (
<div className="mt-2 text-xs text-gray-500">
Last contact: {new Date(client.lastContactedAt).toLocaleDateString()}
</div>
)}
</Link>
))}
</div>
{sorted.length === 0 && (
<div className="text-center py-12 text-gray-500">
No clients match this filter.
</div>
)}
</div>
);
}