xpeditis2.0/apps/frontend/app/[locale]/dashboard/notifications/page.tsx
David ec0173483a
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
fix language
2026-04-21 18:04:02 +02:00

388 lines
17 KiB
TypeScript

'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
import {
listNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification,
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
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';
export default function NotificationsPage() {
const t = useTranslations('dashboard.notificationsPage');
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', 'page', selectedFilter, currentPage],
queryFn: () =>
listNotifications({
page: currentPage,
limit: 20,
isRead: selectedFilter === 'all' ? undefined : selectedFilter === 'read',
}),
});
const notifications = data?.notifications || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
const unreadCount = notifications.filter((n: NotificationResponse) => !n.read).length;
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) {
router.push(notification.actionUrl);
}
};
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm(t('deleteConfirm'))) {
deleteNotificationMutation.mutate(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-blue-500 bg-blue-50 hover:bg-blue-100',
};
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-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-blue-600`} />,
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-blue-500`} />,
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-gray-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-gray-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 (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Bell className="w-8 h-8 text-blue-600" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-600 mt-1">
{t('totalLabel', { count: total })}
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
</p>
</div>
</div>
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<CheckCheck className="w-5 h-5" />
<span>{t('markAllRead')}</span>
</button>
)}
</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-gray-500" />
<span className="text-sm font-medium text-gray-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-blue-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{t(`filter.${filter}` as any)}
{filter === 'unread' && unreadCount > 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-blue-600 mx-auto mb-4" />
<p className="text-gray-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-gray-300" /></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
<p className="text-gray-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-blue-50/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-gray-900">
{notification.title}
</h3>
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 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-gray-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-gray-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-gray-100 rounded-full text-gray-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()}
</span>
{notification.priority && (
<span
className={`px-3 py-1 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-blue-100 text-blue-700'
}`}
>
{getPriorityLabel(notification.priority)}
</span>
)}
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 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-gray-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 bg-white border border-gray-300 rounded-lg hover:bg-gray-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-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
}`}
>
{pageNum}
</button>
);
})}
</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 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>{t('pagination.next')}</span>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}