NeahStable/components/notification-badge-enhanced.tsx
2026-01-16 00:27:07 +01:00

298 lines
13 KiB
TypeScript

import React, { memo, useState, useEffect, useMemo } from 'react';
import Link from 'next/link';
import {
Bell, ExternalLink, AlertCircle, LogIn, Kanban, MessageSquare, Mail, Calendar,
Check, X, Filter, ChevronDown, ChevronUp, RefreshCw
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { useNotifications } from '@/hooks/use-notifications';
import { Button } from '@/components/ui/button';
import { useSession, signIn } from 'next-auth/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu';
import { formatDistanceToNow } from 'date-fns';
import { SafeHTML } from '@/components/safe-html';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface NotificationBadgeProps {
className?: string;
}
type SortOption = 'newest' | 'oldest';
type FilterOption = 'all' | 'email' | 'rocketchat' | 'leantime' | 'calendar';
// Use React.memo to prevent unnecessary re-renders
export const NotificationBadge = memo(function NotificationBadge({ className }: NotificationBadgeProps) {
const { data: session, status } = useSession();
const { notifications, notificationCount, fetchNotifications, markAsRead, markAllAsRead, loading, error } = useNotifications();
const hasUnread = notificationCount.unread > 0;
const [isOpen, setIsOpen] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>('newest');
const [filterBy, setFilterBy] = useState<FilterOption>('all');
const [displayLimit, setDisplayLimit] = useState(10);
const [hasMarkedAsRead, setHasMarkedAsRead] = useState(false);
// When dropdown opens, mark all as read and fetch notifications
useEffect(() => {
if (isOpen && status === 'authenticated') {
// Mark all as read when opening (only once per open)
if (!hasMarkedAsRead) {
markAllAsRead();
setHasMarkedAsRead(true);
}
fetchNotifications(1, 50, filterBy === 'all' ? undefined : filterBy);
} else if (!isOpen) {
// Reset flag when dropdown closes
setHasMarkedAsRead(false);
}
}, [isOpen, status, filterBy, fetchNotifications, markAllAsRead, hasMarkedAsRead]);
// Sort and filter notifications
const sortedAndFilteredNotifications = useMemo(() => {
let filtered = notifications;
// Filter by source
if (filterBy !== 'all') {
filtered = filtered.filter(n => n.source === filterBy);
}
// Sort
const sorted = [...filtered].sort((a, b) => {
const dateA = new Date(a.timestamp).getTime();
const dateB = new Date(b.timestamp).getTime();
return sortBy === 'newest' ? dateB - dateA : dateA - dateB;
});
return sorted.slice(0, displayLimit);
}, [notifications, filterBy, sortBy, displayLimit]);
const handleMarkAsRead = async (notificationId: string, e: React.MouseEvent) => {
e.stopPropagation();
await markAsRead(notificationId);
};
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
};
const handleLoadMore = () => {
setDisplayLimit(prev => prev + 10);
};
const hasMore = notifications.length > displayLimit;
// Special case for auth error
const isAuthError = error?.includes('Not authenticated') || error?.includes('401');
return (
<div className={`relative ${className || ''}`}>
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="text-white/80 hover:text-white relative p-0">
<Bell className='w-5 h-5' />
{hasUnread && (
<Badge
variant="notification"
size="notification"
className="absolute -top-2 -right-2 z-50"
>
{notificationCount.unread > 99 ? '99+' : notificationCount.unread}
</Badge>
)}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-96 max-h-[85vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-base">Notifications</h3>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => fetchNotifications(1, 50, filterBy === 'all' ? undefined : filterBy)}
title="Refresh"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{/* Filters and Sort */}
<div className="p-3 border-b bg-white space-y-2">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select value={filterBy} onValueChange={(v) => setFilterBy(v as FilterOption)}>
<SelectTrigger className="h-8 text-xs flex-1 bg-white border-gray-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="all">Toutes les sources</SelectItem>
<SelectItem value="email">Courrier</SelectItem>
<SelectItem value="rocketchat">Parole</SelectItem>
<SelectItem value="leantime">Agilité</SelectItem>
<SelectItem value="calendar">Agenda</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
<SelectTrigger className="h-8 w-24 text-xs bg-white border-gray-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="newest">Plus récent</SelectItem>
<SelectItem value="oldest">Plus ancien</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Notifications List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="py-8 px-4 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Chargement...</p>
</div>
) : isAuthError ? (
<div className="py-8 px-4 text-center">
<LogIn className="h-8 w-8 text-orange-500 mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-2">Authentification requise</p>
<Button variant="outline" size="sm" onClick={() => signIn()}>
Se connecter
</Button>
</div>
) : error ? (
<div className="py-8 px-4 text-center">
<AlertCircle className="h-8 w-8 text-red-500 mx-auto mb-2" />
<p className="text-sm text-red-500 mb-2">{error}</p>
<Button variant="outline" size="sm" onClick={() => fetchNotifications(1, 50)}>
Réessayer
</Button>
</div>
) : sortedAndFilteredNotifications.length === 0 ? (
<div className="py-8 px-4 text-center">
<p className="text-sm text-muted-foreground">Aucune notification</p>
</div>
) : (
<>
{sortedAndFilteredNotifications.map((notification) => (
<DropdownMenuItem
key={notification.id}
className="px-4 py-3 cursor-pointer hover:bg-gray-50"
onSelect={async (e) => {
e.preventDefault();
// Si on clique sur la notification elle-même et qu'elle a un lien, ouvrir et marquer comme lu
if (notification.link && !notification.isRead) {
await markAsRead(notification.id);
window.location.href = notification.link;
setIsOpen(false);
}
}}
>
<div className="w-full">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
{!notification.isRead && notification.source === 'rocketchat' && (
<span className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0"></span>
)}
<span className="text-sm font-medium truncate">
{notification.title}
</span>
{!notification.isRead && notification.source !== 'rocketchat' && (
<Badge variant="secondary" className="bg-blue-500 text-white text-[10px] px-1.5 py-0">New</Badge>
)}
{notification.source === 'leantime' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-amber-50 text-amber-700 border-amber-200 flex items-center">
<Kanban className="mr-1 h-2.5 w-2.5" />
Agilité
</Badge>
)}
{notification.source === 'rocketchat' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-blue-50 text-blue-700 border-blue-200 flex items-center">
<MessageSquare className="mr-1 h-2.5 w-2.5" />
Parole
</Badge>
)}
{notification.source === 'email' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-green-50 text-green-700 border-green-200 flex items-center">
<Mail className="mr-1 h-2.5 w-2.5" />
Courrier
</Badge>
)}
{notification.source === 'calendar' && (
<Badge variant="outline" className="text-[10px] py-0 px-1.5 bg-purple-50 text-purple-700 border-purple-200 flex items-center">
<Calendar className="mr-1 h-2.5 w-2.5" />
Agenda
</Badge>
)}
</div>
<SafeHTML
html={notification.message}
className="text-xs text-muted-foreground mb-1 notification-message line-clamp-2"
/>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })}
</span>
</div>
<div className="flex items-start gap-1 flex-shrink-0">
{!notification.isRead && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => handleMarkAsRead(notification.id, e)}
title="Marquer comme lu"
>
<Check className="h-3.5 w-3.5" />
</Button>
)}
{notification.link && (
<Link
href={notification.link}
onClick={async () => {
// Marquer comme lu automatiquement quand on ouvre
if (!notification.isRead) {
await markAsRead(notification.id);
}
setIsOpen(false);
}}
>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" title="Ouvrir">
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</Link>
)}
</div>
</div>
</div>
</DropdownMenuItem>
))}
{hasMore && (
<div className="p-2 border-t text-center">
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
className="w-full"
>
Charger plus ({notifications.length - displayLimit} restantes)
</Button>
</div>
)}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
});