feat: Reports & Analytics page, CSV export, notification bell in header
- Reports page with overview stats, client growth chart, email activity chart - Engagement breakdown (engaged/warm/cooling/cold) with stacked bar - Industry and tag distribution charts - At-risk client lists (cold + cooling) - CSV export button downloads all clients - Notification bell in top bar: overdue events, upcoming events, stale clients, pending drafts - Dismissable notifications with priority indicators - Added Reports to sidebar nav between Network and Settings
This commit is contained in:
173
src/components/NotificationBell.tsx
Normal file
173
src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { Bell, AlertTriangle, Calendar, Users, Mail, Clock, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'overdue' | 'upcoming' | 'stale' | 'drafts';
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
link: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface NotifCounts {
|
||||
total: number;
|
||||
high: number;
|
||||
overdue: number;
|
||||
upcoming: number;
|
||||
stale: number;
|
||||
drafts: number;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Calendar> = {
|
||||
overdue: AlertTriangle,
|
||||
upcoming: Calendar,
|
||||
stale: Users,
|
||||
drafts: Mail,
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
overdue: 'text-red-500 bg-red-50',
|
||||
upcoming: 'text-blue-500 bg-blue-50',
|
||||
stale: 'text-amber-500 bg-amber-50',
|
||||
drafts: 'text-purple-500 bg-purple-50',
|
||||
};
|
||||
|
||||
const priorityDot: Record<string, string> = {
|
||||
high: 'bg-red-500',
|
||||
medium: 'bg-amber-400',
|
||||
low: 'bg-slate-300',
|
||||
};
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [counts, setCounts] = useState<NotifCounts | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
if (open) document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const data = await api.getNotifications();
|
||||
setNotifications(data.notifications || []);
|
||||
setCounts(data.counts || null);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setDismissed(prev => new Set(prev).add(id));
|
||||
};
|
||||
|
||||
const visibleNotifs = notifications.filter(n => !dismissed.has(n.id));
|
||||
const activeCount = visibleNotifs.length;
|
||||
const highCount = visibleNotifs.filter(n => n.priority === 'high').length;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'relative p-2 rounded-lg transition-colors',
|
||||
open ? 'bg-slate-100 text-slate-700' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{activeCount > 0 && (
|
||||
<span className={cn(
|
||||
'absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-[10px] font-bold text-white px-1',
|
||||
highCount > 0 ? 'bg-red-500' : 'bg-blue-500'
|
||||
)}>
|
||||
{activeCount > 99 ? '99+' : activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white rounded-xl border border-slate-200 shadow-xl z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Notifications</h3>
|
||||
{counts && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
{counts.overdue > 0 && (
|
||||
<span className="text-red-500 font-medium">{counts.overdue} overdue</span>
|
||||
)}
|
||||
{counts.upcoming > 0 && (
|
||||
<span>{counts.upcoming} upcoming</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{visibleNotifs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<Clock className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">All caught up!</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleNotifs.map(n => {
|
||||
const Icon = typeIcons[n.type] || Calendar;
|
||||
return (
|
||||
<div key={n.id} className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 border-b border-slate-50 last:border-0">
|
||||
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 text-slate-500')}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<Link to={n.link} onClick={() => setOpen(false)} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', priorityDot[n.priority])} />
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{n.title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{n.description}</p>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => dismiss(n.id)}
|
||||
className="p-1 rounded text-slate-300 hover:text-slate-500 hover:bg-slate-100 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleNotifs.length > 0 && (
|
||||
<div className="border-t border-slate-100 px-4 py-2.5">
|
||||
<Link
|
||||
to="/reports"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
View Reports →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user