diff --git a/src/App.tsx b/src/App.tsx index 70eeb89..4047669 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')); const AuditLogPage = lazy(() => import('@/pages/AuditLogPage')); const TagsPage = lazy(() => import('@/pages/TagsPage')); +const EngagementPage = lazy(() => import('@/pages/EngagementPage')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore(); @@ -81,6 +82,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/EngagementBadge.tsx b/src/components/EngagementBadge.tsx new file mode 100644 index 0000000..1194998 --- /dev/null +++ b/src/components/EngagementBadge.tsx @@ -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 = { + 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(null); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + api.getClientEngagement(clientId).then(setData).catch(() => {}); + }, [clientId]); + + if (!data) return null; + + if (compact) { + return ( + + + {data.score} + + ); + } + + return ( +
+ + + {showDetails && ( +
+
+ Engagement Score + {data.score}/100 +
+ +
+ {[ + { 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 }) => ( +
+ {label} +
+
+
+ {value}/{max} +
+ ))} +
+ + {data.recommendations.length > 0 && ( +
+
Recommendations
+
    + {data.recommendations.slice(0, 3).map((rec, i) => ( +
  • + 💡 {rec} +
  • + ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 4e056ba..b72be43 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,7 +6,7 @@ import { api } from '@/lib/api'; import { LayoutDashboard, Users, Calendar, Mail, Settings, Shield, LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command, - FileText, Bookmark, ScrollText, Tag, + FileText, Bookmark, ScrollText, Tag, Zap, } from 'lucide-react'; import NotificationBell from './NotificationBell'; import CommandPalette from './CommandPalette'; @@ -22,6 +22,7 @@ const baseNavItems = [ { path: '/templates', label: 'Templates', icon: FileText }, { path: '/tags', label: 'Tags', icon: Tag }, { path: '/segments', label: 'Segments', icon: Bookmark }, + { path: '/engagement', label: 'Engagement', icon: Zap }, { path: '/reports', label: 'Reports', icon: BarChart3 }, { path: '/settings', label: 'Settings', icon: Settings }, ]; diff --git a/src/lib/api.ts b/src/lib/api.ts index 3250b7f..65372dd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -687,6 +687,84 @@ class ApiClient { document.body.removeChild(a); window.URL.revokeObjectURL(url); } + // ---- Engagement Scoring ---- + + async getEngagementScores(): Promise { + return this.fetch('/engagement'); + } + + async getClientEngagement(clientId: string): Promise { + return this.fetch(`/clients/${clientId}/engagement`); + } + + // ---- Stats Overview ---- + + async getStatsOverview(): Promise { + return this.fetch('/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; + 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; + recentInteractions: { date: string; type: string }[]; + recommendations: string[]; +} + +export interface StatsOverview { + clients: { + total: number; + newThisMonth: number; + stageDistribution: Record; + }; + activity: { + interactions30d: number; + interactions7d: number; + emailsSent30d: number; + interactionsByType: Record; + }; + upcoming: { + events: number; + unreadNotifications: number; + }; + generatedAt: string; } export const api = new ApiClient(); diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index a0bebc3..1fa4bd9 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -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)}
-

- {client.firstName} {client.lastName} -

+
+

+ {client.firstName} {client.lastName} +

+ +
{client.company && (

{client.role ? `${client.role} at ` : ''}{client.company} diff --git a/src/pages/EngagementPage.tsx b/src/pages/EngagementPage.tsx new file mode 100644 index 0000000..5cc8a20 --- /dev/null +++ b/src/pages/EngagementPage.tsx @@ -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 ( +

+ {label} +
+
+
+ {value}/{max} +
+ ); +} + +export default function EngagementPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + const [sortBy, setSortBy] = useState<'score' | 'name' | 'trend'>('score'); + + useEffect(() => { + api.getEngagementScores() + .then(setData) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + if (loading) return
Loading engagement scores...
; + if (!data) return
Failed to load engagement data
; + + 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 ( +
+
+

Engagement Scores

+ + Avg: {summary.averageScore} / 100 + +
+ + {/* Distribution summary */} +
+ {(['highly_engaged', 'engaged', 'warm', 'cooling', 'cold'] as const).map(level => { + const labels: Record = { + highly_engaged: '🔥 Highly Engaged', + engaged: '💚 Engaged', + warm: '🟡 Warm', + cooling: '🟠 Cooling', + cold: '❄️ Cold', + }; + const count = summary.distribution[level] || 0; + return ( + + ); + })} +
+ + {/* Sort controls */} +
+ Sort by: + {(['score', 'name', 'trend'] as const).map(s => ( + + ))} + {filter !== 'all' && ( + + )} +
+ + {/* Client engagement cards */} +
+ {sorted.map(client => ( + +
+
+
+ {client.clientName} +
+
{client.stage}
+
+
+
+ {client.score} +
+
+ {trendIcon(client.trend)} {labelText(client.score)} +
+
+
+ +
+ + + + + +
+ + {client.lastContactedAt && ( +
+ Last contact: {new Date(client.lastContactedAt).toLocaleDateString()} +
+ )} + + ))} +
+ + {sorted.length === 0 && ( +
+ No clients match this filter. +
+ )} +
+ ); +}