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:
@@ -24,6 +24,7 @@ const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
|||||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||||
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
|
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
|
||||||
const TagsPage = lazy(() => import('@/pages/TagsPage'));
|
const TagsPage = lazy(() => import('@/pages/TagsPage'));
|
||||||
|
const EngagementPage = lazy(() => import('@/pages/EngagementPage'));
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
@@ -81,6 +82,7 @@ export default function App() {
|
|||||||
<Route path="segments" element={<PageErrorBoundary><SegmentsPage /></PageErrorBoundary>} />
|
<Route path="segments" element={<PageErrorBoundary><SegmentsPage /></PageErrorBoundary>} />
|
||||||
<Route path="settings" element={<PageErrorBoundary><SettingsPage /></PageErrorBoundary>} />
|
<Route path="settings" element={<PageErrorBoundary><SettingsPage /></PageErrorBoundary>} />
|
||||||
<Route path="admin" element={<PageErrorBoundary><AdminPage /></PageErrorBoundary>} />
|
<Route path="admin" element={<PageErrorBoundary><AdminPage /></PageErrorBoundary>} />
|
||||||
|
<Route path="engagement" element={<PageErrorBoundary><EngagementPage /></PageErrorBoundary>} />
|
||||||
<Route path="audit-log" element={<PageErrorBoundary><AuditLogPage /></PageErrorBoundary>} />
|
<Route path="audit-log" element={<PageErrorBoundary><AuditLogPage /></PageErrorBoundary>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
108
src/components/EngagementBadge.tsx
Normal file
108
src/components/EngagementBadge.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, type ClientEngagement } from '@/lib/api';
|
||||||
|
import { Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
const scoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-emerald-500';
|
||||||
|
if (score >= 60) return 'text-blue-500';
|
||||||
|
if (score >= 40) return 'text-yellow-500';
|
||||||
|
if (score >= 20) return 'text-orange-500';
|
||||||
|
return 'text-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreBgRing = (score: number) => {
|
||||||
|
if (score >= 80) return 'ring-emerald-500/40';
|
||||||
|
if (score >= 60) return 'ring-blue-500/40';
|
||||||
|
if (score >= 40) return 'ring-yellow-500/40';
|
||||||
|
if (score >= 20) return 'ring-orange-500/40';
|
||||||
|
return 'ring-red-500/40';
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelText = (label: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
highly_engaged: 'Highly Engaged',
|
||||||
|
engaged: 'Engaged',
|
||||||
|
warm: 'Warm',
|
||||||
|
cooling: 'Cooling',
|
||||||
|
cold: 'Cold',
|
||||||
|
};
|
||||||
|
return labels[label] || label;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
clientId: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EngagementBadge({ clientId, compact = false }: Props) {
|
||||||
|
const [data, setData] = useState<ClientEngagement | null>(null);
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getClientEngagement(clientId).then(setData).catch(() => {});
|
||||||
|
}, [clientId]);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-sm font-semibold ${scoreColor(data.score)}`}>
|
||||||
|
<Zap className="w-3.5 h-3.5" />
|
||||||
|
{data.score}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-gray-800 ring-2 ${scoreBgRing(data.score)} transition-all hover:shadow-md`}
|
||||||
|
>
|
||||||
|
<Zap className={`w-4 h-4 ${scoreColor(data.score)}`} />
|
||||||
|
<span className={`font-bold text-lg ${scoreColor(data.score)}`}>{data.score}</span>
|
||||||
|
<span className="text-xs text-gray-500">{labelText(data.label)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-4 z-50">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">Engagement Score</span>
|
||||||
|
<span className={`text-2xl font-bold ${scoreColor(data.score)}`}>{data.score}/100</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Recency', value: data.breakdown.recency, max: 40, color: 'bg-emerald-500' },
|
||||||
|
{ label: 'Interactions', value: data.breakdown.interactions, max: 25, color: 'bg-blue-500' },
|
||||||
|
{ label: 'Emails', value: data.breakdown.emails, max: 15, color: 'bg-purple-500' },
|
||||||
|
{ label: 'Events', value: data.breakdown.events, max: 10, color: 'bg-amber-500' },
|
||||||
|
{ label: 'Notes', value: data.breakdown.notes, max: 10, color: 'bg-pink-500' },
|
||||||
|
].map(({ label, value, max, color }) => (
|
||||||
|
<div key={label} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-20 text-gray-500">{label}</span>
|
||||||
|
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${color}`} style={{ width: `${(value / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="w-8 text-right text-gray-400">{value}/{max}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.recommendations.length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||||
|
<div className="text-xs font-medium text-gray-500 mb-1">Recommendations</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{data.recommendations.slice(0, 3).map((rec, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
💡 {rec}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { api } from '@/lib/api';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
||||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
||||||
FileText, Bookmark, ScrollText, Tag,
|
FileText, Bookmark, ScrollText, Tag, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
import CommandPalette from './CommandPalette';
|
import CommandPalette from './CommandPalette';
|
||||||
@@ -22,6 +22,7 @@ const baseNavItems = [
|
|||||||
{ path: '/templates', label: 'Templates', icon: FileText },
|
{ path: '/templates', label: 'Templates', icon: FileText },
|
||||||
{ path: '/tags', label: 'Tags', icon: Tag },
|
{ path: '/tags', label: 'Tags', icon: Tag },
|
||||||
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
||||||
|
{ path: '/engagement', label: 'Engagement', icon: Zap },
|
||||||
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
||||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -687,6 +687,84 @@ class ApiClient {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
// ---- Engagement Scoring ----
|
||||||
|
|
||||||
|
async getEngagementScores(): Promise<EngagementResponse> {
|
||||||
|
return this.fetch<EngagementResponse>('/engagement');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientEngagement(clientId: string): Promise<ClientEngagement> {
|
||||||
|
return this.fetch<ClientEngagement>(`/clients/${clientId}/engagement`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stats Overview ----
|
||||||
|
|
||||||
|
async getStatsOverview(): Promise<StatsOverview> {
|
||||||
|
return this.fetch<StatsOverview>('/stats/overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementScore {
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
score: number;
|
||||||
|
breakdown: {
|
||||||
|
recency: number;
|
||||||
|
interactions: number;
|
||||||
|
emails: number;
|
||||||
|
events: number;
|
||||||
|
notes: number;
|
||||||
|
};
|
||||||
|
lastContactedAt: string | null;
|
||||||
|
stage: string;
|
||||||
|
trend: 'rising' | 'stable' | 'declining';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementResponse {
|
||||||
|
scores: EngagementScore[];
|
||||||
|
summary: {
|
||||||
|
totalClients: number;
|
||||||
|
averageScore: number;
|
||||||
|
distribution: Record<string, number>;
|
||||||
|
topClients: { name: string; score: number }[];
|
||||||
|
needsAttention: { name: string; score: number; lastContactedAt: string | null }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientEngagement {
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
score: number;
|
||||||
|
label: string;
|
||||||
|
breakdown: {
|
||||||
|
recency: number;
|
||||||
|
interactions: number;
|
||||||
|
emails: number;
|
||||||
|
events: number;
|
||||||
|
notes: number;
|
||||||
|
};
|
||||||
|
rawCounts: Record<string, number>;
|
||||||
|
recentInteractions: { date: string; type: string }[];
|
||||||
|
recommendations: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsOverview {
|
||||||
|
clients: {
|
||||||
|
total: number;
|
||||||
|
newThisMonth: number;
|
||||||
|
stageDistribution: Record<string, number>;
|
||||||
|
};
|
||||||
|
activity: {
|
||||||
|
interactions30d: number;
|
||||||
|
interactions7d: number;
|
||||||
|
emailsSent30d: number;
|
||||||
|
interactionsByType: Record<string, number>;
|
||||||
|
};
|
||||||
|
upcoming: {
|
||||||
|
events: number;
|
||||||
|
unreadNotifications: number;
|
||||||
|
};
|
||||||
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import EmailComposeModal from '@/components/EmailComposeModal';
|
|||||||
import ClientNotes from '@/components/ClientNotes';
|
import ClientNotes from '@/components/ClientNotes';
|
||||||
import LogInteractionModal from '@/components/LogInteractionModal';
|
import LogInteractionModal from '@/components/LogInteractionModal';
|
||||||
import MeetingPrepModal from '@/components/MeetingPrepModal';
|
import MeetingPrepModal from '@/components/MeetingPrepModal';
|
||||||
|
import EngagementBadge from '@/components/EngagementBadge';
|
||||||
import type { Interaction } from '@/types';
|
import type { Interaction } from '@/types';
|
||||||
|
|
||||||
export default function ClientDetailPage() {
|
export default function ClientDetailPage() {
|
||||||
@@ -91,9 +92,12 @@ export default function ClientDetailPage() {
|
|||||||
{getInitials(client.firstName, client.lastName)}
|
{getInitials(client.firstName, client.lastName)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
{client.firstName} {client.lastName}
|
{client.firstName} {client.lastName}
|
||||||
</h1>
|
</h1>
|
||||||
|
<EngagementBadge clientId={client.id} />
|
||||||
|
</div>
|
||||||
{client.company && (
|
{client.company && (
|
||||||
<p className="text-slate-500 dark:text-slate-400">
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
{client.role ? `${client.role} at ` : ''}{client.company}
|
{client.role ? `${client.role} at ` : ''}{client.company}
|
||||||
|
|||||||
197
src/pages/EngagementPage.tsx
Normal file
197
src/pages/EngagementPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user