Files
network-app-web/src/components/NotificationBell.tsx

172 lines
6.5 KiB
TypeScript

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 dark:bg-red-900/30',
upcoming: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
stale: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30',
drafts: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
};
const priorityDot: Record<string, string> = {
high: 'bg-red-500',
medium: 'bg-amber-400',
low: 'bg-slate-300 dark:bg-slate-500',
};
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();
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
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 dark:bg-slate-700 text-slate-700 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-600 dark:hover:text-slate-300'
)}
>
<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 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl z-50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Notifications</h3>
{counts && (
<div className="flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
{counts.overdue > 0 && (
<span className="text-red-500 dark:text-red-400 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 dark:text-slate-500">
<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 dark:hover:bg-slate-700 border-b border-slate-50 dark:border-slate-700 last:border-0">
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 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 dark:text-slate-200 truncate">{n.title}</p>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{n.description}</p>
</Link>
<button
onClick={() => dismiss(n.id)}
className="p-1 rounded text-slate-300 dark:text-slate-500 hover:text-slate-500 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-600 flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
})
)}
</div>
{visibleNotifs.length > 0 && (
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-2.5">
<Link
to="/reports"
onClick={() => setOpen(false)}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
>
View Reports
</Link>
</div>
)}
</div>
)}
</div>
);
}