298 lines
13 KiB
TypeScript
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>
|
|
);
|
|
});
|