feat(ui): refonte du menu et de la page de notifications
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
parent
f67de177e8
commit
abe3bfe9ae
@ -1,425 +1,178 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMemo, useState } from 'react';
|
||||||
import { useState } from 'react';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { Bell, CheckCheck } from 'lucide-react';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
|
import { EmptyState, ErrorState, Skeleton } from '@/components/ui/states';
|
||||||
|
import { NotificationRow } from '@/components/notifications/notification-row';
|
||||||
|
import { useNotificationLabels } from '@/components/notifications/use-notification-labels';
|
||||||
import {
|
import {
|
||||||
listNotifications,
|
notificationKind,
|
||||||
markNotificationAsRead,
|
type NotificationKind,
|
||||||
markAllNotificationsAsRead,
|
} from '@/components/notifications/notification-kind';
|
||||||
deleteNotification,
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
} from '@/lib/api';
|
import { cn } from '@/lib/utils';
|
||||||
import type { NotificationResponse } from '@/types/api';
|
import type { NotificationResponse } from '@/types/api';
|
||||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
||||||
import {
|
|
||||||
Trash2,
|
|
||||||
CheckCheck,
|
|
||||||
Filter,
|
|
||||||
Bell,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Package,
|
|
||||||
RefreshCw,
|
|
||||||
XCircle,
|
|
||||||
CheckCircle,
|
|
||||||
Mail,
|
|
||||||
Clock,
|
|
||||||
FileText,
|
|
||||||
Megaphone,
|
|
||||||
User,
|
|
||||||
Building2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
|
/** Familles proposées au filtre, dans l'ordre où elles comptent au quotidien. */
|
||||||
|
const FILTERS = ['all', 'unread', 'booking', 'account', 'system'] as const;
|
||||||
|
type Filter = (typeof FILTERS)[number];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toutes les notifications.
|
||||||
|
*
|
||||||
|
* Cette page existait déjà mais n'était liée de nulle part : « Voir tout »
|
||||||
|
* ouvrait un panneau latéral qui rejouait le menu. Elle est désormais la seule
|
||||||
|
* destination, et partage ses lignes avec la cloche — une notification se
|
||||||
|
* présente pareil où qu'on la lise.
|
||||||
|
*/
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const t = useTranslations('dashboard.notificationsPage');
|
const t = useTranslations('components.notifications');
|
||||||
const locale = useLocale();
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
|
||||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
|
||||||
const tCommon = useTranslations('common');
|
|
||||||
const [notificationToDelete, setNotificationToDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const {
|
||||||
queryKey: ['notifications', 'page', selectedFilter, currentPage],
|
notifications,
|
||||||
queryFn: () =>
|
unreadCount,
|
||||||
listNotifications({
|
isLoading,
|
||||||
page: currentPage,
|
isError,
|
||||||
limit: 20,
|
refetch,
|
||||||
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read',
|
markAsRead,
|
||||||
}),
|
markAllAsRead,
|
||||||
});
|
isMarkingAll,
|
||||||
|
} = useNotifications({ limit: PAGE_SIZE });
|
||||||
|
|
||||||
const notifications = data?.notifications || [];
|
const labels = useNotificationLabels();
|
||||||
const total = data?.total || 0;
|
|
||||||
const totalPages = Math.ceil(total / 20);
|
|
||||||
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
|
|
||||||
|
|
||||||
const markAsReadMutation = useMutation({
|
const visible = useMemo(() => {
|
||||||
mutationFn: markNotificationAsRead,
|
if (filter === 'all') return notifications;
|
||||||
onSuccess: () => {
|
if (filter === 'unread') return notifications.filter(n => !n.read);
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
return notifications.filter(n => matchesKind(n, filter));
|
||||||
},
|
}, [notifications, filter]);
|
||||||
});
|
|
||||||
|
|
||||||
const markAllAsReadMutation = useMutation({
|
const countFor = (value: Filter) => {
|
||||||
mutationFn: markAllNotificationsAsRead,
|
if (value === 'all') return notifications.length;
|
||||||
onSuccess: () => {
|
if (value === 'unread') return unreadCount;
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
return notifications.filter(n => matchesKind(n, value)).length;
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteNotificationMutation = useMutation({
|
|
||||||
mutationFn: deleteNotification,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleNotificationClick = (notification: NotificationResponse) => {
|
|
||||||
if (!notification.read) {
|
|
||||||
markAsReadMutation.mutate(notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.actionUrl) {
|
|
||||||
router.push(notification.actionUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remplace le dialogue natif : non bloquant et traduit dans son entierete.
|
|
||||||
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setNotificationToDelete(notificationId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
const colors = {
|
|
||||||
urgent: 'border-l-4 border-red-500 bg-red-50 hover:bg-red-100',
|
|
||||||
high: 'border-l-4 border-orange-500 bg-orange-50 hover:bg-orange-100',
|
|
||||||
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
|
|
||||||
low: 'border-l-4 border-brand-blue bg-brand-blue/5 hover:bg-brand-blue/10',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300 hover:bg-neutral-100'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityLabel = (priority: string) => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
urgent: t('priority.urgent'),
|
|
||||||
high: t('priority.high'),
|
|
||||||
medium: t('priority.medium'),
|
|
||||||
low: t('priority.low'),
|
|
||||||
};
|
|
||||||
return map[priority] || priority.toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNotificationIcon = (type: string): ReactNode => {
|
|
||||||
const iconClass = 'h-8 w-8';
|
|
||||||
const icons: Record<string, ReactNode> = {
|
|
||||||
booking_created: <Package className={`${iconClass} text-brand-navy`} />,
|
|
||||||
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
|
|
||||||
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
|
|
||||||
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
|
|
||||||
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
|
|
||||||
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
|
|
||||||
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-blue`} />,
|
|
||||||
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
|
|
||||||
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
|
|
||||||
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
|
|
||||||
user_invited: <User className={`${iconClass} text-teal-500`} />,
|
|
||||||
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
|
|
||||||
};
|
|
||||||
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-neutral-500`} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return t('time.now');
|
|
||||||
if (diffMins < 60) return t('time.minutes', { count: diffMins });
|
|
||||||
if (diffHours < 24) return t('time.hours', { count: diffHours });
|
|
||||||
if (diffDays < 7) return t('time.days', { count: diffDays });
|
|
||||||
return date.toLocaleDateString(dateLocale, {
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<>
|
||||||
<div className="bg-white border-b shadow-sm">
|
<PageHeader
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
title={t('pageTitle')}
|
||||||
<div className="flex items-center justify-between">
|
description={t('pageDescription')}
|
||||||
<div className="flex items-center space-x-4">
|
actions={
|
||||||
<div className="p-3 bg-brand-blue/10 rounded-lg">
|
unreadCount > 0 && (
|
||||||
<Bell className="w-8 h-8 text-brand-navy" />
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
<div>
|
size="sm"
|
||||||
<h1 className="text-3xl font-bold text-brand-navy">{t('title')}</h1>
|
onClick={() => markAllAsRead()}
|
||||||
<p className="text-sm text-neutral-600 mt-1">
|
loading={isMarkingAll}
|
||||||
{t('totalLabel', { count: total })}
|
>
|
||||||
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
|
<CheckCheck aria-hidden="true" />
|
||||||
</p>
|
{t('markAllRead')}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
)
|
||||||
{unreadCount > 0 && (
|
}
|
||||||
|
tabs={
|
||||||
|
<div className="flex flex-wrap gap-2" role="tablist" aria-label={t('filterLabel')}>
|
||||||
|
{FILTERS.map(value => {
|
||||||
|
const active = filter === value;
|
||||||
|
const count = countFor(value);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
key={value}
|
||||||
disabled={markAllAsReadMutation.isPending}
|
type="button"
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 transition-colors"
|
role="tab"
|
||||||
>
|
aria-selected={active}
|
||||||
<CheckCheck className="w-5 h-5" />
|
onClick={() => setFilter(value)}
|
||||||
<span>{t('markAllRead')}</span>
|
className={cn(
|
||||||
</button>
|
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-body-sm transition-colors duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
active
|
||||||
|
? 'border-brand-navy bg-brand-navy text-white'
|
||||||
|
: 'border-border bg-white text-neutral-700 hover:bg-neutral-50'
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Filter className="w-5 h-5 text-neutral-500" />
|
|
||||||
<span className="text-sm font-medium text-neutral-700">{t('filter.label')}</span>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
{(['all', 'unread', 'read'] as const).map(filter => (
|
|
||||||
<button
|
|
||||||
key={filter}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedFilter(filter);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
|
||||||
selectedFilter === filter
|
|
||||||
? 'bg-brand-navy text-white shadow-sm'
|
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t(`filter.${filter}` as any)}
|
{t(`filters.${value}`)}
|
||||||
{filter === 'unread' && unreadCount > 0 && (
|
{count > 0 && (
|
||||||
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
|
||||||
{unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-blue mx-auto mb-4" />
|
|
||||||
<p className="text-neutral-500">{t('loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-20">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mb-4 flex justify-center">
|
|
||||||
<Bell className="h-16 w-16 text-neutral-300" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-brand-navy mb-2">{t('empty.title')}</h3>
|
|
||||||
<p className="text-neutral-500">
|
|
||||||
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{notifications.map((notification: NotificationResponse) => (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
className={`p-6 transition-all cursor-pointer group ${
|
|
||||||
!notification.read ? 'bg-brand-blue/5/50' : ''
|
|
||||||
} ${getPriorityColor(notification.priority || 'low')}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
|
|
||||||
{getNotificationIcon(notification.type)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<h3 className="text-lg font-semibold text-brand-navy">
|
|
||||||
{notification.title}
|
|
||||||
</h3>
|
|
||||||
{!notification.read && (
|
|
||||||
<span className="flex items-center space-x-1 px-2 py-1 bg-brand-navy text-white text-xs font-medium rounded-full">
|
|
||||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
|
||||||
<span>{t('new')}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={e => handleDelete(e, notification.id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
|
||||||
title={t('deleteTitle')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-5 h-5 text-red-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-base text-neutral-700 mb-4 leading-relaxed">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center flex-wrap gap-3 text-xs">
|
|
||||||
<span className="flex items-center space-x-1 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">
|
|
||||||
{formatTime(notification.createdAt)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="px-3 py-1 bg-neutral-100 rounded-full text-neutral-700 font-medium">
|
|
||||||
{notification.type.replace(/_/g, ' ').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
{notification.priority && (
|
|
||||||
<span
|
<span
|
||||||
className={`px-3 py-1 rounded-full font-medium ${
|
className={cn(
|
||||||
notification.priority === 'urgent'
|
'tabular-nums text-body-xs',
|
||||||
? 'bg-red-100 text-red-700'
|
active ? 'text-white/70' : 'text-neutral-400'
|
||||||
: notification.priority === 'high'
|
)}
|
||||||
? 'bg-orange-100 text-orange-700'
|
|
||||||
: notification.priority === 'medium'
|
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
|
||||||
: 'bg-brand-blue/10 text-brand-navy'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{getPriorityLabel(notification.priority)}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
{notification.actionUrl && (
|
|
||||||
<span className="text-sm text-brand-navy font-medium group-hover:underline flex items-center space-x-1">
|
|
||||||
<span>{t('viewDetails')}</span>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm text-neutral-600">
|
|
||||||
{t.rich('pagination.info', {
|
|
||||||
current: currentPage,
|
|
||||||
total: totalPages,
|
|
||||||
items: total,
|
|
||||||
b: chunks => <span className="font-semibold">{chunks}</span>,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
<span>{t('pagination.previous')}</span>
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
||||||
let pageNum;
|
|
||||||
if (totalPages <= 5) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage <= 3) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage >= totalPages - 2) {
|
|
||||||
pageNum = totalPages - 4 + i;
|
|
||||||
} else {
|
|
||||||
pageNum = currentPage - 2 + i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={pageNum}
|
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
|
||||||
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
|
|
||||||
currentPage === pageNum
|
|
||||||
? 'bg-brand-navy text-white'
|
|
||||||
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pageNum}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
}
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
/>
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
<div className="overflow-hidden rounded-lg border border-border bg-white">
|
||||||
>
|
{isLoading ? (
|
||||||
<span>{t('pagination.next')}</span>
|
<div className="space-y-4 p-5" aria-hidden="true">
|
||||||
<ChevronRight className="w-4 h-4" />
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
</button>
|
<div key={index} className="flex gap-3">
|
||||||
|
<Skeleton className="size-9 shrink-0 rounded-md" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-3 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-3/4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState
|
||||||
|
title={t('errorTitle')}
|
||||||
|
description={t('errorBody')}
|
||||||
|
retryLabel={t('retry')}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
/>
|
||||||
|
) : visible.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Bell}
|
||||||
|
title={filter === 'all' ? t('emptyTitle') : t('emptyFilterTitle')}
|
||||||
|
description={filter === 'all' ? t('emptyBody') : t('emptyFilterBody')}
|
||||||
|
action={
|
||||||
|
filter !== 'all' && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setFilter('all')}>
|
||||||
|
{t('filters.all')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{visible.map(notification => (
|
||||||
|
<li key={notification.id}>
|
||||||
|
<NotificationRow
|
||||||
|
notification={notification}
|
||||||
|
labels={labels}
|
||||||
|
density="comfortable"
|
||||||
|
onOpen={n => !n.read && markAsRead(n.id)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(notificationToDelete)}
|
|
||||||
onOpenChange={open => !open && setNotificationToDelete(null)}
|
|
||||||
title={t('deleteConfirm')}
|
|
||||||
confirmLabel={tCommon('delete')}
|
|
||||||
cancelLabel={tCommon('cancel')}
|
|
||||||
variant="destructive"
|
|
||||||
onConfirm={() => {
|
|
||||||
if (notificationToDelete) deleteNotificationMutation.mutate(notificationToDelete);
|
|
||||||
setNotificationToDelete(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `document` et `rate` se rangent avec les réservations : c'est le même dossier. */
|
||||||
|
function matchesKind(notification: NotificationResponse, filter: Filter): boolean {
|
||||||
|
const kind = notificationKind(notification.type);
|
||||||
|
const family: Record<Exclude<Filter, 'all' | 'unread'>, NotificationKind[]> = {
|
||||||
|
booking: ['booking', 'document', 'rate'],
|
||||||
|
account: ['account'],
|
||||||
|
system: ['system'],
|
||||||
|
};
|
||||||
|
return family[filter as Exclude<Filter, 'all' | 'unread'>].includes(kind);
|
||||||
|
}
|
||||||
|
|||||||
@ -717,54 +717,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bookingDetail": {
|
"bookingDetail": {
|
||||||
"back": "← Back to bookings",
|
"back": "Back to bookings",
|
||||||
"notFound": "Booking not found",
|
"notFound": "Booking not found",
|
||||||
"timeline": {
|
"notFoundBody": "It may have been deleted, or it does not belong to you.",
|
||||||
"title": "Timeline",
|
"errorTitle": "Booking unavailable",
|
||||||
"created": "Booking Created"
|
"errorBody": "The booking could not be loaded. Try again in a moment.",
|
||||||
},
|
"requestedOn": "Requested on {date}",
|
||||||
"createdOn": "Created on {date}",
|
"carrier": "Carrier: {carrier}",
|
||||||
"downloadPdf": "Download PDF",
|
"transitDays": "{days} d transit",
|
||||||
"pdfNotImplemented": "PDF download functionality is not yet implemented",
|
"volume": "Volume",
|
||||||
"status": {
|
"weight": "Weight",
|
||||||
"draft": "Draft",
|
"documents": "{count, plural, =0 {Documents} one {# document} other {# documents}}",
|
||||||
"pending": "Pending",
|
"noDocuments": "No document attached",
|
||||||
"confirmed": "Confirmed",
|
"freight": "Freight",
|
||||||
"in_transit": "In transit",
|
"destinationCharges": "Destination charges",
|
||||||
"delivered": "Delivered",
|
"commission": "Xpeditis commission",
|
||||||
"cancelled": "Cancelled"
|
"timeline": "Timeline",
|
||||||
},
|
"requested": "Request sent",
|
||||||
"cargo": {
|
"responded": "Carrier reply",
|
||||||
"title": "Cargo Details",
|
"reference": "Internal reference",
|
||||||
"description": "Description",
|
"rejected": "Request rejected",
|
||||||
"specialInstructions": "Special Instructions"
|
"packing": "Packing"
|
||||||
},
|
|
||||||
"containers": {
|
|
||||||
"title": "Containers ({count})",
|
|
||||||
"type": "Type",
|
|
||||||
"number": "Container Number",
|
|
||||||
"seal": "Seal Number",
|
|
||||||
"vgm": "VGM (kg)"
|
|
||||||
},
|
|
||||||
"shipper": {
|
|
||||||
"title": "Shipper",
|
|
||||||
"name": "Name",
|
|
||||||
"contact": "Contact",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Phone"
|
|
||||||
},
|
|
||||||
"consignee": {
|
|
||||||
"title": "Consignee",
|
|
||||||
"name": "Name",
|
|
||||||
"contact": "Contact",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Phone"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"title": "Information",
|
|
||||||
"bookingId": "Booking ID",
|
|
||||||
"lastUpdated": "Last Updated"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"loading": "Loading profile...",
|
"loading": "Loading profile...",
|
||||||
@ -3181,45 +3154,6 @@
|
|||||||
"Avoid critical shipments during high-risk periods"
|
"Avoid critical shipments during high-risk periods"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"notificationsPage": {
|
|
||||||
"title": "Notifications",
|
|
||||||
"totalLabel": "{count, plural, one {# notification total} other {# notifications total}}",
|
|
||||||
"unreadSuffix": " • {count, plural, one {# unread} other {# unread}}",
|
|
||||||
"markAllRead": "Mark all as read",
|
|
||||||
"filter": {
|
|
||||||
"label": "Filter:",
|
|
||||||
"all": "All",
|
|
||||||
"unread": "Unread",
|
|
||||||
"read": "Read"
|
|
||||||
},
|
|
||||||
"loading": "Loading notifications...",
|
|
||||||
"empty": {
|
|
||||||
"title": "No notifications",
|
|
||||||
"upToDate": "You're all caught up!",
|
|
||||||
"none": "No notifications to display"
|
|
||||||
},
|
|
||||||
"new": "NEW",
|
|
||||||
"deleteTitle": "Delete notification",
|
|
||||||
"deleteConfirm": "Are you sure you want to delete this notification?",
|
|
||||||
"viewDetails": "View details",
|
|
||||||
"priority": {
|
|
||||||
"urgent": "URGENT",
|
|
||||||
"high": "HIGH",
|
|
||||||
"medium": "MEDIUM",
|
|
||||||
"low": "LOW"
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"now": "Just now",
|
|
||||||
"minutes": "{count}m ago",
|
|
||||||
"hours": "{count}h ago",
|
|
||||||
"days": "{count}d ago"
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"info": "Page <b>{current}</b> of <b>{total}</b> • <b>{items}</b> {items, plural, one {notification} other {notifications}} total",
|
|
||||||
"previous": "Previous",
|
|
||||||
"next": "Next"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@ -3383,42 +3317,43 @@
|
|||||||
"bulkUpdate": "Bulk update",
|
"bulkUpdate": "Bulk update",
|
||||||
"bulkUpdateSoon": "Bulk update is coming soon!"
|
"bulkUpdateSoon": "Bulk update is coming soon!"
|
||||||
},
|
},
|
||||||
"notificationDropdown": {
|
"notifications": {
|
||||||
"ariaLabel": "Notifications",
|
"ariaLabel": "Notifications",
|
||||||
|
"ariaLabelWithCount": "{count, plural, one {# unread notification} other {# unread notifications}}",
|
||||||
"header": "Notifications",
|
"header": "Notifications",
|
||||||
"markAllRead": "Mark all as read",
|
"markAllRead": "Mark all as read",
|
||||||
"loading": "Loading notifications…",
|
"viewAll": "See all notifications",
|
||||||
"empty": "No new notifications",
|
"unread": "Unread",
|
||||||
"viewAll": "View all notifications",
|
"emptyTitle": "You are up to date",
|
||||||
|
"emptyBody": "Carrier replies and changes to your bookings will show up here.",
|
||||||
|
"emptyFilterTitle": "Nothing in this filter",
|
||||||
|
"emptyFilterBody": "No notification matches this family right now.",
|
||||||
|
"errorTitle": "Notifications unavailable",
|
||||||
|
"errorBody": "Your notifications could not be loaded. Try again in a moment.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"pageTitle": "Notifications",
|
||||||
|
"pageDescription": "Carrier replies and changes to your bookings.",
|
||||||
|
"filterLabel": "Filter notifications",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"unread": "Unread",
|
||||||
|
"booking": "Bookings",
|
||||||
|
"account": "Account",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"booking": "Booking {reference}",
|
||||||
|
"bookings": "Your bookings",
|
||||||
|
"rates": "Rate search",
|
||||||
|
"account": "Your organisation",
|
||||||
|
"bookingUnnamed": "The related booking"
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"now": "Just now",
|
"now": "Just now",
|
||||||
"minutes": "{minutes} min ago",
|
"minutes": "{minutes} min ago",
|
||||||
"hours": "{hours} h ago",
|
"hours": "{hours} h ago",
|
||||||
"days": "{days} d ago"
|
"days": "{days} d ago"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"notificationPanel": {
|
|
||||||
"title": "Notifications",
|
|
||||||
"totalCount": "{count, plural, one {# notification total} other {# notifications total}}",
|
|
||||||
"closeAria": "Close panel",
|
|
||||||
"filters": {
|
|
||||||
"all": "All",
|
|
||||||
"unread": "Unread",
|
|
||||||
"read": "Read"
|
|
||||||
},
|
|
||||||
"markAllRead": "Mark all as read",
|
|
||||||
"loading": "Loading notifications…",
|
|
||||||
"emptyTitle": "No notifications",
|
|
||||||
"emptyUnread": "You're all caught up!",
|
|
||||||
"emptyAll": "Nothing to show",
|
|
||||||
"deleteConfirm": "Are you sure you want to delete this notification?",
|
|
||||||
"deleteTitle": "Delete notification",
|
|
||||||
"viewDetails": "View details →",
|
|
||||||
"pagination": {
|
|
||||||
"page": "Page {current} of {total}",
|
|
||||||
"previous": "Previous",
|
|
||||||
"next": "Next"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"carrierPortal": {
|
"carrierPortal": {
|
||||||
|
|||||||
@ -717,54 +717,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bookingDetail": {
|
"bookingDetail": {
|
||||||
"back": "← Retour aux réservations",
|
"back": "Retour aux réservations",
|
||||||
"notFound": "Réservation introuvable",
|
"notFound": "Réservation introuvable",
|
||||||
"timeline": {
|
"notFoundBody": "Elle a peut-être été supprimée, ou elle ne vous appartient pas.",
|
||||||
"title": "Chronologie",
|
"errorTitle": "Réservation indisponible",
|
||||||
"created": "Réservation créée"
|
"errorBody": "Le dossier n’a pas pu être chargé. Réessayez dans un instant.",
|
||||||
},
|
"requestedOn": "Demandée le {date}",
|
||||||
"createdOn": "Créée le {date}",
|
"carrier": "Transporteur : {carrier}",
|
||||||
"downloadPdf": "Télécharger le PDF",
|
"transitDays": "{days} j de transit",
|
||||||
"pdfNotImplemented": "Le téléchargement PDF n'est pas encore disponible",
|
"volume": "Volume",
|
||||||
"status": {
|
"weight": "Poids",
|
||||||
"draft": "Brouillon",
|
"documents": "{count, plural, =0 {Documents} one {# document} other {# documents}}",
|
||||||
"pending": "En attente",
|
"noDocuments": "Aucun document joint",
|
||||||
"confirmed": "Confirmée",
|
"freight": "Fret",
|
||||||
"in_transit": "En transit",
|
"destinationCharges": "Frais à destination",
|
||||||
"delivered": "Livrée",
|
"commission": "Commission Xpeditis",
|
||||||
"cancelled": "Annulée"
|
"timeline": "Suivi",
|
||||||
},
|
"requested": "Demande envoyée",
|
||||||
"cargo": {
|
"responded": "Réponse du transporteur",
|
||||||
"title": "Détails de la cargaison",
|
"reference": "Référence interne",
|
||||||
"description": "Description",
|
"rejected": "Demande refusée",
|
||||||
"specialInstructions": "Instructions spéciales"
|
"packing": "Colisage"
|
||||||
},
|
|
||||||
"containers": {
|
|
||||||
"title": "Conteneurs ({count})",
|
|
||||||
"type": "Type",
|
|
||||||
"number": "Numéro du conteneur",
|
|
||||||
"seal": "Numéro de plomb",
|
|
||||||
"vgm": "VGM (kg)"
|
|
||||||
},
|
|
||||||
"shipper": {
|
|
||||||
"title": "Expéditeur",
|
|
||||||
"name": "Nom",
|
|
||||||
"contact": "Contact",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Téléphone"
|
|
||||||
},
|
|
||||||
"consignee": {
|
|
||||||
"title": "Destinataire",
|
|
||||||
"name": "Nom",
|
|
||||||
"contact": "Contact",
|
|
||||||
"email": "Email",
|
|
||||||
"phone": "Téléphone"
|
|
||||||
},
|
|
||||||
"info": {
|
|
||||||
"title": "Informations",
|
|
||||||
"bookingId": "ID de réservation",
|
|
||||||
"lastUpdated": "Dernière mise à jour"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"loading": "Chargement du profil...",
|
"loading": "Chargement du profil...",
|
||||||
@ -3181,45 +3154,6 @@
|
|||||||
"Éviter les expéditions critiques pendant les périodes à risque"
|
"Éviter les expéditions critiques pendant les périodes à risque"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"notificationsPage": {
|
|
||||||
"title": "Notifications",
|
|
||||||
"totalLabel": "{count, plural, one {# notification au total} other {# notifications au total}}",
|
|
||||||
"unreadSuffix": " • {count, plural, one {# non lue} other {# non lues}}",
|
|
||||||
"markAllRead": "Tout marquer comme lu",
|
|
||||||
"filter": {
|
|
||||||
"label": "Filtrer :",
|
|
||||||
"all": "Toutes",
|
|
||||||
"unread": "Non lues",
|
|
||||||
"read": "Lues"
|
|
||||||
},
|
|
||||||
"loading": "Chargement des notifications...",
|
|
||||||
"empty": {
|
|
||||||
"title": "Aucune notification",
|
|
||||||
"upToDate": "Vous êtes à jour !",
|
|
||||||
"none": "Aucune notification à afficher"
|
|
||||||
},
|
|
||||||
"new": "NOUVEAU",
|
|
||||||
"deleteTitle": "Supprimer la notification",
|
|
||||||
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette notification ?",
|
|
||||||
"viewDetails": "Voir les détails",
|
|
||||||
"priority": {
|
|
||||||
"urgent": "URGENT",
|
|
||||||
"high": "ÉLEVÉE",
|
|
||||||
"medium": "MOYENNE",
|
|
||||||
"low": "FAIBLE"
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"now": "À l'instant",
|
|
||||||
"minutes": "Il y a {count}min",
|
|
||||||
"hours": "Il y a {count}h",
|
|
||||||
"days": "Il y a {count}j"
|
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"info": "Page <b>{current}</b> sur <b>{total}</b> • <b>{items}</b> {items, plural, one {notification} other {notifications}} au total",
|
|
||||||
"previous": "Précédent",
|
|
||||||
"next": "Suivant"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@ -3383,41 +3317,42 @@
|
|||||||
"bulkUpdate": "Mise à jour groupée",
|
"bulkUpdate": "Mise à jour groupée",
|
||||||
"bulkUpdateSoon": "La mise à jour groupée arrive bientôt !"
|
"bulkUpdateSoon": "La mise à jour groupée arrive bientôt !"
|
||||||
},
|
},
|
||||||
"notificationDropdown": {
|
"notifications": {
|
||||||
"ariaLabel": "Notifications",
|
"ariaLabel": "Notifications",
|
||||||
|
"ariaLabelWithCount": "{count, plural, one {# notification non lue} other {# notifications non lues}}",
|
||||||
"header": "Notifications",
|
"header": "Notifications",
|
||||||
"markAllRead": "Tout marquer comme lu",
|
"markAllRead": "Tout marquer comme lu",
|
||||||
"loading": "Chargement des notifications…",
|
|
||||||
"empty": "Aucune nouvelle notification",
|
|
||||||
"viewAll": "Voir toutes les notifications",
|
"viewAll": "Voir toutes les notifications",
|
||||||
"time": {
|
"unread": "Non lue",
|
||||||
"now": "À l'instant",
|
"emptyTitle": "Vous êtes à jour",
|
||||||
"minutes": "Il y a {minutes} min",
|
"emptyBody": "Les réponses de vos transporteurs et les changements sur vos dossiers apparaîtront ici.",
|
||||||
"hours": "Il y a {hours} h",
|
"emptyFilterTitle": "Rien dans ce filtre",
|
||||||
"days": "Il y a {days} j"
|
"emptyFilterBody": "Aucune notification ne correspond à cette famille pour le moment.",
|
||||||
}
|
"errorTitle": "Notifications indisponibles",
|
||||||
},
|
"errorBody": "Vos notifications n’ont pas pu être chargées. Réessayez dans un instant.",
|
||||||
"notificationPanel": {
|
"retry": "Réessayer",
|
||||||
"title": "Notifications",
|
"pageTitle": "Notifications",
|
||||||
"totalCount": "{count, plural, one {# notification au total} other {# notifications au total}}",
|
"pageDescription": "Les réponses de vos transporteurs et les changements sur vos dossiers.",
|
||||||
"closeAria": "Fermer le panneau",
|
"filterLabel": "Filtrer les notifications",
|
||||||
"filters": {
|
"filters": {
|
||||||
"all": "Toutes",
|
"all": "Toutes",
|
||||||
"unread": "Non lues",
|
"unread": "Non lues",
|
||||||
"read": "Lues"
|
"booking": "Réservations",
|
||||||
|
"account": "Compte",
|
||||||
|
"system": "Système"
|
||||||
},
|
},
|
||||||
"markAllRead": "Tout marquer comme lu",
|
"destination": {
|
||||||
"loading": "Chargement des notifications…",
|
"booking": "Réservation {reference}",
|
||||||
"emptyTitle": "Aucune notification",
|
"bookings": "Vos réservations",
|
||||||
"emptyUnread": "Vous êtes à jour !",
|
"rates": "Recherche de tarifs",
|
||||||
"emptyAll": "Aucune notification à afficher",
|
"account": "Votre organisation",
|
||||||
"deleteConfirm": "Voulez-vous vraiment supprimer cette notification ?",
|
"bookingUnnamed": "La réservation concernée"
|
||||||
"deleteTitle": "Supprimer la notification",
|
},
|
||||||
"viewDetails": "Voir les détails →",
|
"time": {
|
||||||
"pagination": {
|
"now": "À l’instant",
|
||||||
"page": "Page {current} sur {total}",
|
"minutes": "Il y a {minutes} min",
|
||||||
"previous": "Précédent",
|
"hours": "Il y a {hours} h",
|
||||||
"next": "Suivant"
|
"days": "Il y a {days} j"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,231 +0,0 @@
|
|||||||
/**
|
|
||||||
* Notification Dropdown Component
|
|
||||||
*
|
|
||||||
* Displays real-time notifications with mark as read functionality
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
|
||||||
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
|
|
||||||
import type { NotificationResponse } from '@/types/api';
|
|
||||||
import NotificationPanel from './NotificationPanel';
|
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
RefreshCw,
|
|
||||||
XCircle,
|
|
||||||
DollarSign,
|
|
||||||
Ship,
|
|
||||||
Settings,
|
|
||||||
AlertTriangle,
|
|
||||||
Bell,
|
|
||||||
Megaphone,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export default function NotificationDropdown() {
|
|
||||||
const t = useTranslations('components.notificationDropdown');
|
|
||||||
const locale = useLocale();
|
|
||||||
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Fetch unread notifications
|
|
||||||
const { data: notificationsData, isLoading } = useQuery({
|
|
||||||
queryKey: ['notifications', 'unread'],
|
|
||||||
queryFn: () => listNotifications({ isRead: false, limit: 10 }),
|
|
||||||
refetchInterval: 30000, // Refetch every 30 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
const notifications = notificationsData?.notifications || [];
|
|
||||||
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
|
|
||||||
|
|
||||||
// Mark single notification as read
|
|
||||||
const markAsReadMutation = useMutation({
|
|
||||||
mutationFn: markNotificationAsRead,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mark all as read
|
|
||||||
const markAllAsReadMutation = useMutation({
|
|
||||||
mutationFn: markAllNotificationsAsRead,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown when clicking 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 handleNotificationClick = (notification: NotificationResponse) => {
|
|
||||||
if (!notification.read) {
|
|
||||||
markAsReadMutation.mutate(notification.id);
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
|
|
||||||
if (notification.actionUrl) {
|
|
||||||
window.location.href = notification.actionUrl;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNotificationIcon = (type: string): LucideIcon => {
|
|
||||||
const icons: Record<string, LucideIcon> = {
|
|
||||||
BOOKING_CONFIRMED: CheckCircle,
|
|
||||||
BOOKING_UPDATED: RefreshCw,
|
|
||||||
BOOKING_CANCELLED: XCircle,
|
|
||||||
RATE_ALERT: DollarSign,
|
|
||||||
CARRIER_UPDATE: Ship,
|
|
||||||
SYSTEM: Settings,
|
|
||||||
WARNING: AlertTriangle,
|
|
||||||
};
|
|
||||||
return icons[type] || Megaphone;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return t('time.now');
|
|
||||||
if (diffMins < 60) return t('time.minutes', { minutes: diffMins });
|
|
||||||
if (diffHours < 24) return t('time.hours', { hours: diffHours });
|
|
||||||
if (diffDays < 7) return t('time.days', { days: diffDays });
|
|
||||||
return date.toLocaleDateString(dateLocale);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
{/* Notification Bell Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="relative p-2 text-neutral-600 hover:text-brand-navy hover:bg-neutral-100 rounded-lg transition-colors"
|
|
||||||
aria-label={t('ariaLabel')}
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
|
|
||||||
{unreadCount > 9 ? '9+' : unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Panel */}
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-border z-50">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
||||||
<h3 className="text-sm font-semibold text-brand-navy">{t('header')}</h3>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
|
||||||
disabled={markAllAsReadMutation.isPending}
|
|
||||||
className="text-xs text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('markAllRead')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications List */}
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-4 text-center text-sm text-neutral-500">{t('loading')}</div>
|
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<Bell className="h-10 w-10 text-neutral-300 mx-auto mb-2" />
|
|
||||||
<p className="text-sm text-neutral-500">{t('empty')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{notifications.map((notification: NotificationResponse) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${
|
|
||||||
!notification.read ? 'bg-brand-blue/5' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{(() => {
|
|
||||||
const Icon = getNotificationIcon(notification.type);
|
|
||||||
return <Icon className="h-5 w-5 text-neutral-500" />;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<p className="text-sm font-medium text-brand-navy truncate">
|
|
||||||
{notification.title}
|
|
||||||
</p>
|
|
||||||
{!notification.read && (
|
|
||||||
<span className="w-2 h-2 bg-brand-blue rounded-full"></span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-neutral-600 line-clamp-2">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
|
||||||
<span className="text-xs text-neutral-500">
|
|
||||||
{formatTime(notification.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-4 py-3 border-t bg-neutral-50">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setIsPanelOpen(true);
|
|
||||||
}}
|
|
||||||
className="w-full text-center text-sm text-brand-navy hover:text-brand-blue font-medium py-2 hover:bg-brand-blue/5 rounded transition-colors"
|
|
||||||
>
|
|
||||||
{t('viewAll')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Notification Panel (Sidebar) */}
|
|
||||||
<NotificationPanel isOpen={isPanelOpen} onClose={() => setIsPanelOpen(false)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,356 +0,0 @@
|
|||||||
/**
|
|
||||||
* Notification Panel Component
|
|
||||||
*
|
|
||||||
* Sidebar panel that displays all notifications with detailed view
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import {
|
|
||||||
listNotifications,
|
|
||||||
markNotificationAsRead,
|
|
||||||
markAllNotificationsAsRead,
|
|
||||||
deleteNotification,
|
|
||||||
} from '@/lib/api';
|
|
||||||
import type { NotificationResponse } from '@/types/api';
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Trash2,
|
|
||||||
CheckCheck,
|
|
||||||
Filter,
|
|
||||||
Bell,
|
|
||||||
Package,
|
|
||||||
RefreshCw,
|
|
||||||
XCircle,
|
|
||||||
CheckCircle,
|
|
||||||
Mail,
|
|
||||||
Timer,
|
|
||||||
FileText,
|
|
||||||
Megaphone,
|
|
||||||
User,
|
|
||||||
Building2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useConfirm } from '@/components/ui/use-confirm';
|
|
||||||
|
|
||||||
interface NotificationPanelProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const t = useTranslations('components.notificationPanel');
|
|
||||||
const tTime = useTranslations('components.notificationDropdown.time');
|
|
||||||
const locale = useLocale();
|
|
||||||
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
|
||||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['notifications', 'panel', selectedFilter, currentPage],
|
|
||||||
queryFn: () =>
|
|
||||||
listNotifications({
|
|
||||||
page: currentPage,
|
|
||||||
limit: 20,
|
|
||||||
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read',
|
|
||||||
}),
|
|
||||||
enabled: isOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
const notifications = data?.notifications || [];
|
|
||||||
const total = data?.total || 0;
|
|
||||||
const totalPages = Math.ceil(total / 20);
|
|
||||||
|
|
||||||
const markAsReadMutation = useMutation({
|
|
||||||
mutationFn: markNotificationAsRead,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const markAllAsReadMutation = useMutation({
|
|
||||||
mutationFn: markAllNotificationsAsRead,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteNotificationMutation = useMutation({
|
|
||||||
mutationFn: deleteNotification,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleNotificationClick = (notification: NotificationResponse) => {
|
|
||||||
if (!notification.read) {
|
|
||||||
markAsReadMutation.mutate(notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.actionUrl) {
|
|
||||||
onClose();
|
|
||||||
router.push(notification.actionUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (e: React.MouseEvent, notificationId: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (await confirm({ title: t('deleteConfirm'), destructive: true })) {
|
|
||||||
deleteNotificationMutation.mutate(notificationId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
const colors = {
|
|
||||||
urgent: 'border-l-4 border-red-500 bg-red-50',
|
|
||||||
high: 'border-l-4 border-orange-500 bg-orange-50',
|
|
||||||
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
|
|
||||||
low: 'border-l-4 border-brand-blue bg-brand-blue/5',
|
|
||||||
};
|
|
||||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNotificationIconComponent = (type: string) => {
|
|
||||||
const icons: Record<string, typeof Bell> = {
|
|
||||||
booking_created: Package,
|
|
||||||
booking_updated: RefreshCw,
|
|
||||||
booking_cancelled: XCircle,
|
|
||||||
booking_confirmed: CheckCircle,
|
|
||||||
csv_booking_accepted: CheckCircle,
|
|
||||||
csv_booking_rejected: XCircle,
|
|
||||||
csv_booking_request_sent: Mail,
|
|
||||||
rate_quote_expiring: Timer,
|
|
||||||
document_uploaded: FileText,
|
|
||||||
system_announcement: Megaphone,
|
|
||||||
user_invited: User,
|
|
||||||
organization_update: Building2,
|
|
||||||
};
|
|
||||||
return icons[type.toLowerCase()] || Bell;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return tTime('now');
|
|
||||||
if (diffMins < 60) return tTime('minutes', { minutes: diffMins });
|
|
||||||
if (diffHours < 24) return tTime('hours', { hours: diffHours });
|
|
||||||
if (diffDays < 7) return tTime('days', { days: diffDays });
|
|
||||||
return date.toLocaleDateString(dateLocale, {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="fixed inset-0 bg-black/30 z-40 transition-opacity" onClick={onClose} />
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div className="fixed right-0 top-0 bottom-0 w-full max-w-2xl bg-white shadow-2xl z-50 flex flex-col animate-slide-in-right">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-brand-navy">{t('title')}</h2>
|
|
||||||
<p className="text-sm text-neutral-600 mt-1">{t('totalCount', { count: total })}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors text-neutral-600"
|
|
||||||
aria-label={t('closeAria')}
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Bar */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-b bg-neutral-50">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Filter className="w-4 h-4 text-neutral-500" />
|
|
||||||
<div className="flex space-x-1">
|
|
||||||
{(['all', 'unread', 'read'] as const).map(filter => (
|
|
||||||
<button
|
|
||||||
key={filter}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedFilter(filter);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
||||||
selectedFilter === filter
|
|
||||||
? 'bg-brand-navy text-white'
|
|
||||||
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`filters.${filter}` as any)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedFilter === 'unread' && notifications.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
|
||||||
disabled={markAllAsReadMutation.isPending}
|
|
||||||
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<CheckCheck className="w-4 h-4" />
|
|
||||||
<span>{t('markAllRead')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications List */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4" />
|
|
||||||
<p className="text-sm text-neutral-500">{t('loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<Bell className="h-16 w-16 text-neutral-300 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold text-brand-navy mb-2">{t('emptyTitle')}</h3>
|
|
||||||
<p className="text-sm text-neutral-500">
|
|
||||||
{selectedFilter === 'unread' ? t('emptyUnread') : t('emptyAll')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{notifications.map((notification: NotificationResponse) => (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
|
|
||||||
!notification.read ? 'bg-brand-blue/5/50' : ''
|
|
||||||
} ${getPriorityColor(notification.priority || 'low')}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start space-x-4">
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{(() => {
|
|
||||||
const Icon = getNotificationIconComponent(notification.type);
|
|
||||||
return <Icon className="h-6 w-6 text-neutral-500" />;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h3 className="text-base font-semibold text-brand-navy">
|
|
||||||
{notification.title}
|
|
||||||
</h3>
|
|
||||||
{!notification.read && (
|
|
||||||
<span className="w-2.5 h-2.5 bg-brand-blue rounded-full" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={e => handleDelete(e, notification.id)}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
|
|
||||||
title={t('deleteTitle')}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 text-red-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-neutral-700 mb-3 leading-relaxed">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-4 text-xs text-neutral-500">
|
|
||||||
<span className="font-medium">{formatTime(notification.createdAt)}</span>
|
|
||||||
<span className="px-2 py-0.5 bg-neutral-200 rounded-full text-neutral-700 font-medium">
|
|
||||||
{notification.type.replace(/_/g, ' ').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
{notification.priority && (
|
|
||||||
<span
|
|
||||||
className={`px-2 py-0.5 rounded-full font-medium ${
|
|
||||||
notification.priority === 'urgent'
|
|
||||||
? 'bg-red-100 text-red-700'
|
|
||||||
: notification.priority === 'high'
|
|
||||||
? 'bg-orange-100 text-orange-700'
|
|
||||||
: notification.priority === 'medium'
|
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
|
||||||
: 'bg-brand-blue/10 text-brand-navy'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{notification.priority.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{notification.actionUrl && (
|
|
||||||
<span className="text-xs text-brand-navy font-medium group-hover:underline">
|
|
||||||
{t('viewDetails')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t bg-neutral-50">
|
|
||||||
<div className="text-sm text-neutral-600">
|
|
||||||
{t('pagination.page', { current: currentPage, total: totalPages })}
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{t('pagination.previous')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{t('pagination.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
@keyframes slide-in-right {
|
|
||||||
from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-slide-in-right {
|
|
||||||
animation: slide-in-right 0.3s ease-out;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
Megaphone,
|
||||||
|
Package,
|
||||||
|
Tag,
|
||||||
|
UserPlus,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Famille d'une notification : ce qu'elle concerne.
|
||||||
|
*
|
||||||
|
* L'icone n'est pas une decoration : c'est le premier repere qui permet de
|
||||||
|
* balayer une liste sans lire. Elle est donc indexee sur les **valeurs reelles**
|
||||||
|
* de l'enumeration du backend, en minuscules. La table precedente etait ecrite
|
||||||
|
* en MAJUSCULES et la moitie de ses cles n'existaient pas : toutes les
|
||||||
|
* notifications, sans exception, tombaient sur l'icone par defaut.
|
||||||
|
*/
|
||||||
|
export type NotificationKind = 'booking' | 'document' | 'rate' | 'account' | 'system';
|
||||||
|
|
||||||
|
const KIND_BY_TYPE: Record<string, NotificationKind> = {
|
||||||
|
booking_created: 'booking',
|
||||||
|
booking_updated: 'booking',
|
||||||
|
booking_cancelled: 'booking',
|
||||||
|
booking_confirmed: 'booking',
|
||||||
|
csv_booking_accepted: 'booking',
|
||||||
|
csv_booking_rejected: 'booking',
|
||||||
|
csv_booking_request_sent: 'booking',
|
||||||
|
document_uploaded: 'document',
|
||||||
|
rate_quote_expiring: 'rate',
|
||||||
|
user_invited: 'account',
|
||||||
|
organization_update: 'account',
|
||||||
|
system_announcement: 'system',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function notificationKind(type: string): NotificationKind {
|
||||||
|
return KIND_BY_TYPE[type?.toLowerCase()] ?? 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une teinte par famille, prises dans la charte du produit.
|
||||||
|
*
|
||||||
|
* Le refus d'un transporteur n'est pas peint en rouge : le statut appartient au
|
||||||
|
* dossier, pas au canal qui l'annonce. Colorer la ligne rendrait une liste de
|
||||||
|
* dix notifications illisible.
|
||||||
|
*/
|
||||||
|
export const KIND_STYLE: Record<NotificationKind, { icon: LucideIcon; tile: string }> = {
|
||||||
|
booking: { icon: Package, tile: 'bg-brand-blue-soft text-brand-blue' },
|
||||||
|
document: { icon: FileText, tile: 'bg-neutral-100 text-neutral-600' },
|
||||||
|
rate: { icon: Tag, tile: 'bg-neutral-100 text-neutral-600' },
|
||||||
|
account: { icon: UserPlus, tile: 'bg-neutral-100 text-brand-navy' },
|
||||||
|
system: { icon: Megaphone, tile: 'bg-amber-50 text-amber-700' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Famille des mises a jour d'organisation, distinguee d'une invitation. */
|
||||||
|
export const ORGANISATION_ICON: LucideIcon = Building2;
|
||||||
138
apps/frontend/src/components/notifications/notification-menu.tsx
Normal file
138
apps/frontend/src/components/notifications/notification-menu.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Bell, CheckCheck } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Skeleton } from '@/components/ui/states';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import type { NotificationResponse } from '@/types/api';
|
||||||
|
import { NotificationRow } from './notification-row';
|
||||||
|
import { useNotificationLabels } from './use-notification-labels';
|
||||||
|
|
||||||
|
/** Au-dela, le menu devient une liste : c'est le role de la page. */
|
||||||
|
const PREVIEW_COUNT = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cloche et apercu des notifications.
|
||||||
|
*
|
||||||
|
* Remplace un menu ecrit a la main — etat local, ecouteur de clic exterieur,
|
||||||
|
* `<svg>` de cloche recopie, `window.location.href` — par les primitives du
|
||||||
|
* produit : `Popover` gere la fermeture, le focus et l'echappement, et `Link`
|
||||||
|
* navigue sans recharger l'application ni perdre le prefixe de langue.
|
||||||
|
*
|
||||||
|
* Le panneau lateral qui doublait cet apercu a ete supprime : « Voir tout »
|
||||||
|
* mene desormais a la page `/dashboard/notifications`, qui existait deja mais
|
||||||
|
* n'etait liee de nulle part.
|
||||||
|
*/
|
||||||
|
export function NotificationMenu() {
|
||||||
|
const t = useTranslations('components.notifications');
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead, isMarkingAll } =
|
||||||
|
useNotifications({ unreadOnly: false, limit: PREVIEW_COUNT });
|
||||||
|
|
||||||
|
const labels = useNotificationLabels();
|
||||||
|
|
||||||
|
const openNotification = (notification: NotificationResponse) => {
|
||||||
|
if (!notification.read) markAsRead(notification.id);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="relative"
|
||||||
|
aria-label={
|
||||||
|
unreadCount > 0 ? t('ariaLabelWithCount', { count: unreadCount }) : t('ariaLabel')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bell />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
// Pastille de comptage : le chiffre exact vient du serveur, la
|
||||||
|
// version precedente comptait les non-lues de la page chargee et
|
||||||
|
// plafonnait donc a dix.
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -right-0.5 -top-0.5 flex min-w-[1.125rem] items-center justify-center rounded-full bg-brand-blue px-1 text-[0.625rem] font-semibold leading-4 text-white"
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent align="end" className="w-[22rem] p-0 sm:w-[26rem]">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h2 className="truncate font-heading text-body-sm font-semibold text-brand-navy">
|
||||||
|
{t('header')}
|
||||||
|
</h2>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => markAllAsRead()}
|
||||||
|
loading={isMarkingAll}
|
||||||
|
className="shrink-0 text-neutral-500 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<CheckCheck aria-hidden="true" />
|
||||||
|
{t('markAllRead')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[24rem] overflow-y-auto" role="log" aria-live="polite">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3 p-4" aria-hidden="true">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div key={index} className="flex gap-3">
|
||||||
|
<Skeleton className="size-8 shrink-0 rounded-md" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="px-6 py-10 text-center">
|
||||||
|
<Bell className="mx-auto mb-3 size-8 text-neutral-300" aria-hidden="true" />
|
||||||
|
<p className="font-heading text-body-sm font-medium text-brand-navy">
|
||||||
|
{t('emptyTitle')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-body-xs leading-5 text-neutral-500">{t('emptyBody')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{notifications.map(notification => (
|
||||||
|
<li key={notification.id}>
|
||||||
|
<NotificationRow
|
||||||
|
notification={notification}
|
||||||
|
labels={labels}
|
||||||
|
onOpen={openNotification}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<Button asChild variant="ghost" size="sm" className="w-full">
|
||||||
|
<Link href="/dashboard/notifications" onClick={() => setOpen(false)}>
|
||||||
|
{t('viewAll')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conserve pour les imports existants du chrome applicatif. */
|
||||||
|
export default NotificationMenu;
|
||||||
127
apps/frontend/src/components/notifications/notification-row.tsx
Normal file
127
apps/frontend/src/components/notifications/notification-row.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { Link } from '@/i18n/navigation';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { NotificationResponse } from '@/types/api';
|
||||||
|
import { KIND_STYLE, notificationKind } from './notification-kind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une notification dans une liste.
|
||||||
|
*
|
||||||
|
* Le parti pris : **une notification est un pointeur, pas un message.** La ligne
|
||||||
|
* entiere est donc la cible du clic, et elle annonce ou elle mene avant qu'on
|
||||||
|
* clique — « Réservation XPD-2026-… » plutot qu'une fleche muette. Une
|
||||||
|
* notification sans destination n'est pas rendue cliquable : mieux vaut un
|
||||||
|
* texte inerte qu'un clic qui ne fait rien, ce qui etait le cas de toutes les
|
||||||
|
* notifications du produit.
|
||||||
|
*
|
||||||
|
* Le non-lu est porte par la structure — un filet vertical et la graisse du
|
||||||
|
* titre — et non par un aplat de couleur : dix lignes teintees ne se lisent pas.
|
||||||
|
*/
|
||||||
|
export interface NotificationRowLabels {
|
||||||
|
destination: (notification: NotificationResponse) => string | null;
|
||||||
|
time: (iso: string) => string;
|
||||||
|
unread: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationRowProps {
|
||||||
|
notification: NotificationResponse;
|
||||||
|
labels: NotificationRowLabels;
|
||||||
|
/** Marque comme lue ; declenche aussi la navigation quand il y a une cible. */
|
||||||
|
onOpen: (notification: NotificationResponse) => void;
|
||||||
|
/** Densite : `compact` dans le menu, `comfortable` sur la page. */
|
||||||
|
density?: 'compact' | 'comfortable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationRow({
|
||||||
|
notification,
|
||||||
|
labels,
|
||||||
|
onOpen,
|
||||||
|
density = 'compact',
|
||||||
|
}: NotificationRowProps) {
|
||||||
|
const kind = notificationKind(notification.type);
|
||||||
|
const { icon: Icon, tile } = KIND_STYLE[kind];
|
||||||
|
const destination = labels.destination(notification);
|
||||||
|
const unread = !notification.read;
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center rounded-md',
|
||||||
|
density === 'compact' ? 'size-8' : 'size-9',
|
||||||
|
tile
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon className={density === 'compact' ? 'size-4' : 'size-[18px]'} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex items-baseline justify-between gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'truncate font-heading text-body-sm text-brand-navy',
|
||||||
|
unread ? 'font-semibold' : 'font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 whitespace-nowrap text-body-xs text-neutral-400">
|
||||||
|
{labels.time(notification.createdAt)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 block text-body-sm leading-6 text-neutral-600',
|
||||||
|
density === 'compact' && 'line-clamp-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{notification.message}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{destination && (
|
||||||
|
// Annoncer la destination rend le clic previsible : on sait ou l'on va
|
||||||
|
// avant d'y aller, ce qui compte quand la liste melange les sujets.
|
||||||
|
<span className="mt-2 inline-flex items-center gap-1 text-body-xs font-medium text-brand-blue">
|
||||||
|
{destination}
|
||||||
|
<ArrowRight className="size-3" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const shell = cn(
|
||||||
|
'flex w-full gap-3 border-l-2 px-4 text-left transition-colors duration-150 ease-out',
|
||||||
|
density === 'compact' ? 'py-3' : 'py-4',
|
||||||
|
unread ? 'border-brand-blue bg-brand-blue-soft/40' : 'border-transparent'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!notification.actionUrl) {
|
||||||
|
return (
|
||||||
|
<div className={shell}>
|
||||||
|
{body}
|
||||||
|
{unread && <span className="sr-only">{labels.unread}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={notification.actionUrl}
|
||||||
|
onClick={() => onOpen(notification)}
|
||||||
|
className={cn(
|
||||||
|
shell,
|
||||||
|
'hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
||||||
|
unread && 'hover:bg-brand-blue-soft/70'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
{unread && <span className="sr-only">{labels.unread}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import type { NotificationResponse } from '@/types/api';
|
||||||
|
import type { NotificationRowLabels } from './notification-row';
|
||||||
|
import { notificationKind } from './notification-kind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Libelles partages par le menu et la page.
|
||||||
|
*
|
||||||
|
* La destination affichee est deduite du **type** et des metadonnees, pas de
|
||||||
|
* l'URL : « Réservation XPD-2026-R9KE8U » se lit, `/dashboard/bookings/b1e2…`
|
||||||
|
* non. Quand la reference du dossier est connue, elle est nommee ; sinon la
|
||||||
|
* destination reste generique plutot que d'inventer un intitule.
|
||||||
|
*/
|
||||||
|
export function useNotificationLabels(): NotificationRowLabels {
|
||||||
|
const t = useTranslations('components.notifications');
|
||||||
|
const locale = useLocale();
|
||||||
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
|
|
||||||
|
const destination = useCallback(
|
||||||
|
(notification: NotificationResponse): string | null => {
|
||||||
|
if (!notification.actionUrl) return null;
|
||||||
|
|
||||||
|
switch (notificationKind(notification.type)) {
|
||||||
|
case 'booking':
|
||||||
|
case 'document': {
|
||||||
|
const reference = notification.metadata?.bookingNumber;
|
||||||
|
if (typeof reference === 'string' && reference) {
|
||||||
|
return t('destination.booking', { reference });
|
||||||
|
}
|
||||||
|
// Le lien vise un dossier precis mais son numero n'a pas ete
|
||||||
|
// enregistre : le nommer au singulier reste honnete, la ou « vos
|
||||||
|
// reservations » laisserait croire a la liste.
|
||||||
|
return notification.metadata?.bookingId
|
||||||
|
? t('destination.bookingUnnamed')
|
||||||
|
: t('destination.bookings');
|
||||||
|
}
|
||||||
|
case 'rate':
|
||||||
|
return t('destination.rates');
|
||||||
|
case 'account':
|
||||||
|
return t('destination.account');
|
||||||
|
case 'system':
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const time = useCallback(
|
||||||
|
(iso: string): string => {
|
||||||
|
const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
|
||||||
|
if (minutes < 1) return t('time.now');
|
||||||
|
if (minutes < 60) return t('time.minutes', { minutes });
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return t('time.hours', { hours });
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) return t('time.days', { days });
|
||||||
|
|
||||||
|
return new Date(iso).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' });
|
||||||
|
},
|
||||||
|
[t, dateLocale]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => ({ destination, time, unread: t('unread') }), [destination, time, t]);
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import { Menu, Search } from 'lucide-react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
import { NotificationMenu } from '@/components/notifications/notification-menu';
|
||||||
import { Breadcrumb } from './breadcrumb';
|
import { Breadcrumb } from './breadcrumb';
|
||||||
import type { NavGroup, ShellVariant } from './nav-config';
|
import type { NavGroup, ShellVariant } from './nav-config';
|
||||||
|
|
||||||
@ -90,7 +90,7 @@ export function Topbar({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<LanguageSwitcher variant="light" />
|
<LanguageSwitcher variant="light" />
|
||||||
{showNotifications && <NotificationDropdown />}
|
{showNotifications && <NotificationMenu />}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,108 +1,77 @@
|
|||||||
/**
|
/**
|
||||||
* useNotifications Hook
|
* useNotifications
|
||||||
*
|
*
|
||||||
* Custom hook for managing notifications with automatic polling
|
* Source unique des notifications : la cloche, la page et tout futur écran
|
||||||
* Polls the API every 30 seconds for new notifications
|
* lisent le même état.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
|
import {
|
||||||
|
getUnreadNotificationCount,
|
||||||
|
listNotifications,
|
||||||
|
markAllNotificationsAsRead,
|
||||||
|
markNotificationAsRead,
|
||||||
|
} from '@/lib/api';
|
||||||
import type { NotificationResponse } from '@/types/api';
|
import type { NotificationResponse } from '@/types/api';
|
||||||
|
|
||||||
interface UseNotificationsOptions {
|
interface UseNotificationsOptions {
|
||||||
/**
|
/** Ne remonter que les non-lues. Par défaut, tout, du plus récent au plus ancien. */
|
||||||
* Whether to fetch only unread notifications
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
unreadOnly?: boolean;
|
unreadOnly?: boolean;
|
||||||
/**
|
/** Nombre maximum de notifications chargées. */
|
||||||
* Maximum number of notifications to fetch
|
|
||||||
* @default 10
|
|
||||||
*/
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
/**
|
/** Intervalle de rafraîchissement, en millisecondes. */
|
||||||
* Polling interval in milliseconds
|
|
||||||
* @default 30000 (30 seconds)
|
|
||||||
*/
|
|
||||||
refetchInterval?: number;
|
refetchInterval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotifications(options: UseNotificationsOptions = {}) {
|
export function useNotifications(options: UseNotificationsOptions = {}) {
|
||||||
const { unreadOnly = true, limit = 10, refetchInterval = 30000 } = options;
|
const { unreadOnly = false, limit = 10, refetchInterval = 30000 } = options;
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch notifications with automatic polling
|
const list = useQuery({
|
||||||
const { data, isLoading, refetch, error } = useQuery({
|
queryKey: ['notifications', { unreadOnly, limit }],
|
||||||
queryKey: ['notifications', { isRead: !unreadOnly, limit }],
|
queryFn: () => listNotifications(unreadOnly ? { isRead: false, limit } : { limit }),
|
||||||
queryFn: () => listNotifications({ isRead: !unreadOnly, limit }),
|
refetchInterval,
|
||||||
refetchInterval, // Poll every 30 seconds by default
|
refetchOnWindowFocus: true,
|
||||||
refetchOnWindowFocus: true, // Refetch when window regains focus
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const notifications = data?.notifications || [];
|
/**
|
||||||
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
|
* Le compteur vient du serveur.
|
||||||
|
*
|
||||||
|
* Il etait auparavant calcule sur la page chargee — dix elements — et ne
|
||||||
|
* pouvait donc jamais depasser dix, quel que soit le nombre reel de non-lues.
|
||||||
|
*/
|
||||||
|
const count = useQuery({
|
||||||
|
queryKey: ['notifications', 'unread-count'],
|
||||||
|
queryFn: getUnreadNotificationCount,
|
||||||
|
refetchInterval,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
|
||||||
// Mark single notification as read
|
|
||||||
const markAsReadMutation = useMutation({
|
const markAsReadMutation = useMutation({
|
||||||
mutationFn: markNotificationAsRead,
|
mutationFn: markNotificationAsRead,
|
||||||
onSuccess: () => {
|
onSuccess: invalidate,
|
||||||
// Invalidate all notification queries to refetch
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark all notifications as read
|
|
||||||
const markAllAsReadMutation = useMutation({
|
const markAllAsReadMutation = useMutation({
|
||||||
mutationFn: markAllNotificationsAsRead,
|
mutationFn: markAllNotificationsAsRead,
|
||||||
onSuccess: () => {
|
onSuccess: invalidate,
|
||||||
// Invalidate all notification queries to refetch
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const notifications: NotificationResponse[] = list.data?.notifications ?? [];
|
||||||
* Mark a single notification as read
|
|
||||||
* @param id - Notification ID
|
|
||||||
*/
|
|
||||||
const markAsRead = async (id: string) => {
|
|
||||||
return markAsReadMutation.mutateAsync(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark all notifications as read
|
|
||||||
*/
|
|
||||||
const markAllAsRead = async () => {
|
|
||||||
return markAllAsReadMutation.mutateAsync();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually refetch notifications
|
|
||||||
*/
|
|
||||||
const refresh = () => {
|
|
||||||
return refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Array of notifications */
|
|
||||||
notifications,
|
notifications,
|
||||||
/** Count of unread notifications */
|
total: list.data?.total ?? notifications.length,
|
||||||
unreadCount,
|
unreadCount: count.data?.count ?? 0,
|
||||||
/** Mark a single notification as read */
|
isLoading: list.isPending,
|
||||||
markAsRead,
|
isError: list.isError,
|
||||||
/** Mark all notifications as read */
|
refetch: list.refetch,
|
||||||
markAllAsRead,
|
markAsRead: markAsReadMutation.mutate,
|
||||||
/** Manually refresh notifications */
|
markAllAsRead: markAllAsReadMutation.mutate,
|
||||||
refresh,
|
isMarkingAll: markAllAsReadMutation.isPending,
|
||||||
/** Loading state */
|
|
||||||
isLoading,
|
|
||||||
/** Error state */
|
|
||||||
error,
|
|
||||||
/** Whether marking as read is in progress */
|
|
||||||
isMarkingAsRead: markAsReadMutation.isPending,
|
|
||||||
/** Whether marking all as read is in progress */
|
|
||||||
isMarkingAllAsRead: markAllAsReadMutation.isPending,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,7 @@ export {
|
|||||||
// Notifications (7 endpoints)
|
// Notifications (7 endpoints)
|
||||||
export {
|
export {
|
||||||
listNotifications,
|
listNotifications,
|
||||||
|
getUnreadNotificationCount,
|
||||||
getNotification,
|
getNotification,
|
||||||
createNotification,
|
createNotification,
|
||||||
markNotificationAsRead,
|
markNotificationAsRead,
|
||||||
|
|||||||
@ -53,6 +53,14 @@ export async function listNotifications(params?: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre exact de notifications non lues.
|
||||||
|
* GET /api/v1/notifications/unread/count
|
||||||
|
*/
|
||||||
|
export async function getUnreadNotificationCount(): Promise<{ count: number }> {
|
||||||
|
return get<{ count: number }>('/api/v1/notifications/unread/count');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get notification by ID
|
* Get notification by ID
|
||||||
* GET /api/v1/notifications/:id
|
* GET /api/v1/notifications/:id
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user