Qassure-frontend/src/components/shared/NotificationBell.tsx

254 lines
10 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { Bell, Clipboard, FileText, GraduationCap, GitGraph, Building2, Info, CheckCircle2, AlertCircle, AlertTriangle, Trash2, X } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { markReadAsync, readAllAsync, fetchNotifications } from '@/store/notificationSlice';
import { formatDistanceToNow } from 'date-fns';
import { cn } from '@/lib/utils';
import { useNavigate } from 'react-router-dom';
import type { Notification, NotificationType, NotificationCategory } from '@/types/notification';
import { notificationService } from '@/services/notification-service';
import { showToast } from '@/utils/toast';
const getNotificationIcon = (category?: NotificationCategory, type?: NotificationType) => {
switch (category) {
case 'capa': return <Clipboard className="w-4 h-4" />;
case 'document': return <FileText className="w-4 h-4" />;
case 'training': return <GraduationCap className="w-4 h-4" />;
case 'workflow': return <GitGraph className="w-4 h-4" />;
case 'supplier': return <Building2 className="w-4 h-4" />;
case 'system': return <Bell className="w-4 h-4" />;
default:
switch (type) {
case 'success': return <CheckCircle2 className="w-4 h-4" />;
case 'warning': return <AlertTriangle className="w-4 h-4" />;
case 'action_required': return <AlertCircle className="w-4 h-4" />;
default: return <Info className="w-4 h-4" />;
}
}
};
const getTypeColor = (type: NotificationType) => {
switch (type) {
case 'success': return 'text-green-500 bg-green-50';
case 'warning': return 'text-orange-500 bg-orange-50';
case 'action_required': return 'text-amber-500 bg-amber-50';
case 'escalation': return 'text-red-500 bg-red-50';
default: return 'text-blue-500 bg-blue-50';
}
};
export const NotificationBell = () => {
const [isOpen, setIsOpen] = useState(false);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { notifications, unread_count } = useAppSelector((state) => state.notifications);
const { roles } = useAppSelector((state) => state.auth);
const dropdownRef = useRef<HTMLDivElement>(null);
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleMarkAllRead = () => {
dispatch(readAllAsync());
};
const handleNotificationClick = (notification: Notification) => {
if (!notification.is_read) {
dispatch(markReadAsync(notification.id));
}
// Special handling for tasks as requested - redirect to My Tasks tab
if (['workflow', 'training'].includes(notification.category || '')) {
navigate('/tenant/tasks');
setIsOpen(false);
return;
}
// Special handling for system as requested - redirect to Dashboard
if (['system'].includes(notification.category || '')) {
navigate('/tenant');
setIsOpen(false);
return;
}
if (notification.action_url) {
navigate(notification.action_url);
}
setIsOpen(false);
};
const handleDismiss = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
try {
await notificationService.dismiss(id);
dispatch(fetchNotifications({ limit: 20 }));
showToast.success('Notification dismissed');
} catch (error) {
showToast.error('Failed to dismiss notification');
}
};
const handleDismissAll = async () => {
try {
await notificationService.dismissAll();
dispatch(fetchNotifications({ limit: 20 }));
showToast.success('All notifications dismissed');
} catch (error) {
showToast.error('Failed to dismiss all notifications');
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative w-10 h-10 flex items-center justify-center rounded-full border border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
aria-label="Notifications"
>
<Bell className="w-5 h-5 text-gray-600" />
{unread_count > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[20px] h-5 px-1.5 flex items-center justify-center bg-red-500 text-white text-[10px] font-bold rounded-full border-2 border-white">
{unread_count > 99 ? '99+' : unread_count}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-3 w-[400px] max-w-[90vw] bg-white border border-gray-200 rounded-xl shadow-2xl z-[100] flex flex-col overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
<div className="flex items-center gap-2">
{unread_count > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
>
Mark all as read
</button>
)}
<button
onClick={handleDismissAll}
className="text-gray-400 hover:text-gray-600 transition-colors"
title="Dismiss all"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto max-h-[480px]">
{notifications.length === 0 ? (
<div className="py-12 px-4 flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
<Bell className="w-6 h-6 text-gray-300" />
</div>
<p className="text-sm font-medium text-gray-900">All caught up!</p>
<p className="text-xs text-gray-500 mt-1">No notifications to show right now.</p>
</div>
) : (
<div className="divide-y divide-gray-50">
{notifications.map((notification) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={cn(
"group relative px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer flex gap-3",
!notification.is_read && "bg-blue-50/30"
)}
>
<div className={cn(
"flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center",
getTypeColor(notification.notification_type)
)}>
{getNotificationIcon(notification.category, notification.notification_type)}
</div>
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-start justify-between mb-0.5">
<p className={cn(
"text-sm font-medium text-gray-900 truncate pr-2",
!notification.is_read && "font-bold"
)}>
{notification.title}
</p>
<span className="text-[10px] text-gray-400 whitespace-nowrap mt-0.5">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
</div>
<p className="text-xs text-gray-600 line-clamp-2 leading-relaxed">
{notification.message}
</p>
{notification.entity_name && (
<div className="mt-2 text-[10px] font-medium text-gray-400 flex items-center gap-1">
<span className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-500 uppercase tracking-wider">
{notification.entity_type?.replace('_', ' ')}
</span>
<span className="truncate">{notification.entity_name}</span>
</div>
)}
</div>
{/* Quick Actions */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleDismiss(e, notification.id)}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Dismiss"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Unread indicator dot */}
{!notification.is_read && (
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" />
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
<button
onClick={() => {
const isSuperAdmin = roles.includes('super_admin');
navigate(isSuperAdmin ? '/notifications' : '/tenant/notifications');
setIsOpen(false);
}}
className="text-xs font-medium text-blue-600 hover:text-blue-700 transition-colors"
>
View All Notifications
</button>
<button
onClick={() => {
const isSuperAdmin = roles.includes('super_admin');
navigate(isSuperAdmin ? '/settings/notifications' : '/tenant/settings/notifications');
setIsOpen(false);
}}
className="text-xs font-medium text-gray-500 hover:text-gray-900 transition-colors"
>
Preferences
</button>
</div>
</div>
)}
</div>
);
};