172 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|