xpeditis2.0/apps/frontend/src/components/NotificationPanel.tsx
2025-12-22 12:32:32 +01:00

345 lines
13 KiB
TypeScript

/**
* 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 { useRouter } from 'next/navigation';
import {
listNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
deleteNotification
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import { X, Trash2, CheckCheck, Filter } from 'lucide-react';
interface NotificationPanelProps {
isOpen: boolean;
onClose: () => void;
}
export default function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
const router = useRouter();
// Fetch notifications with pagination
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);
// 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'] });
},
});
// Delete notification
const deleteNotificationMutation = useMutation({
mutationFn: deleteNotification,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const handleNotificationClick = (notification: NotificationResponse) => {
if (!notification.read) {
markAsReadMutation.mutate(notification.id);
}
// Navigate to actionUrl if available
if (notification.actionUrl) {
onClose();
router.push(notification.actionUrl);
}
};
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this notification?')) {
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-blue-500 bg-blue-50',
};
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
};
const getNotificationIcon = (type: string) => {
const icons: Record<string, string> = {
booking_created: '📦',
booking_updated: '🔄',
booking_cancelled: '❌',
booking_confirmed: '✅',
csv_booking_accepted: '✅',
csv_booking_rejected: '❌',
csv_booking_request_sent: '📧',
rate_quote_expiring: '⏰',
document_uploaded: '📄',
system_announcement: '📢',
user_invited: '👤',
organization_update: '🏢',
};
return icons[type.toLowerCase()] || '🔔';
};
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 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
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-gray-900">Notifications</h2>
<p className="text-sm text-gray-600 mt-1">
{total} notification{total !== 1 ? 's' : ''} total
</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
aria-label="Close panel"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Filter Bar */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-gray-50">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-gray-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-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
}`}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</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-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
<span>Mark all as read</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-blue-600 mx-auto mb-4" />
<p className="text-sm text-gray-500">Loading notifications...</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-6xl mb-4">🔔</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">No notifications</h3>
<p className="text-sm text-gray-500">
{selectedFilter === 'unread'
? "You're all caught up!"
: 'No notifications to display'}
</p>
</div>
</div>
) : (
<div className="divide-y">
{notifications.map((notification: NotificationResponse) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 hover:bg-gray-50 transition-all cursor-pointer group ${
!notification.read ? 'bg-blue-50/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0 text-3xl">
{getNotificationIcon(notification.type)}
</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-gray-900">
{notification.title}
</h3>
{!notification.read && (
<span className="w-2.5 h-2.5 bg-blue-600 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="Delete notification"
>
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
<p className="text-sm text-gray-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-gray-500">
<span className="font-medium">
{formatTime(notification.createdAt)}
</span>
<span className="px-2 py-0.5 bg-gray-200 rounded-full text-gray-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-blue-100 text-blue-700'
}`}
>
{notification.priority.toUpperCase()}
</span>
)}
</div>
{notification.actionUrl && (
<span className="text-xs text-blue-600 font-medium group-hover:underline">
View details
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
<div className="text-sm text-gray-600">
Page {currentPage} of {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-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
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-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
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>
</>
);
}