feat: implement notifications system with API endpoints for fetching and marking notifications as read

This commit is contained in:
2025-12-02 11:06:31 +01:00
parent fae7615818
commit 9b84c6b9e8
8 changed files with 694 additions and 13 deletions

View File

@@ -12,6 +12,9 @@ const Navigation = () => {
const { t } = useTranslation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false);
const notificationsRef = useRef(null);
// Close notifications dropdown when clicking outside
@@ -28,6 +31,109 @@ const Navigation = () => {
};
}, []);
// Fetch notifications when component mounts or session changes
useEffect(() => {
if (session?.user?.id) {
fetchUnreadCount();
}
}, [session]);
// Fetch notifications when dropdown opens
useEffect(() => {
if (isNotificationsOpen && session?.user?.id) {
fetchNotifications();
}
}, [isNotificationsOpen, session]);
const fetchUnreadCount = async () => {
try {
const response = await fetch('/api/notifications/unread-count');
if (response.ok) {
const data = await response.json();
setUnreadCount(data.unreadCount);
}
} catch (error) {
console.error('Failed to fetch unread count:', error);
}
};
const fetchNotifications = async () => {
setIsLoadingNotifications(true);
try {
const response = await fetch('/api/notifications?limit=10');
if (response.ok) {
const data = await response.json();
setNotifications(data.notifications);
}
} catch (error) {
console.error('Failed to fetch notifications:', error);
} finally {
setIsLoadingNotifications(false);
}
};
const markAsRead = async (notificationIds = null) => {
try {
const response = await fetch('/api/notifications', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'markAsRead',
notificationIds,
}),
});
if (response.ok) {
const data = await response.json();
setUnreadCount(data.unreadCount);
// Update local notifications state
if (notificationIds) {
setNotifications(prev =>
prev.map(notif =>
notificationIds.includes(notif.id)
? { ...notif, is_read: 1 }
: notif
)
);
} else {
setNotifications(prev =>
prev.map(notif => ({ ...notif, is_read: 1 }))
);
}
}
} catch (error) {
console.error('Failed to mark notifications as read:', error);
}
};
const handleNotificationClick = (notification) => {
// Mark as read if not already read
if (!notification.is_read) {
markAsRead([notification.id]);
}
// Navigate to action URL if available
if (notification.action_url) {
window.location.href = notification.action_url;
}
setIsNotificationsOpen(false);
};
const formatTimeAgo = (timestamp) => {
const now = new Date();
const notificationTime = new Date(timestamp);
const diffInMinutes = Math.floor((now - notificationTime) / (1000 * 60));
if (diffInMinutes < 1) return t('notifications.justNow') || 'Just now';
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`;
return `${Math.floor(diffInMinutes / 1440)}d ago`;
};
const isActive = (path) => {
if (path === "/") return pathname === "/";
if (pathname === path) return true;
@@ -101,8 +207,8 @@ const Navigation = () => {
<div className="text-blue-100">{t('navigation.loading')}</div>
) : session ? (
<>
{/* Notifications - Admin only for now */}
{session?.user?.role === 'admin' && (
{/* Notifications - Admin and Team Lead */}
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
<div className="relative" ref={notificationsRef}>
<button
onClick={() => setIsNotificationsOpen(!isNotificationsOpen)}
@@ -113,21 +219,74 @@ const Navigation = () => {
</svg>
{/* Notification badge */}
<span className="absolute -top-1 -right-1 h-4 w-4 bg-gray-400 dark:bg-gray-500 text-white text-xs rounded-full flex items-center justify-center">
0
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</button>
{/* Notifications Dropdown */}
{isNotificationsOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{t('notifications.title')}</h3>
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
{t('notifications.title')}
</h3>
{notifications.length > 0 && (
<button
onClick={() => markAsRead()}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{t('notifications.markAllRead') || 'Mark all read'}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.noNotifications')}</p>
<p className="text-xs mt-1">{t('notifications.placeholder')}</p>
</div>
{isLoadingNotifications ? (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.loading') || 'Loading...'}</p>
</div>
) : notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer ${
!notification.is_read ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<div className={`w-2 h-2 rounded-full ${
notification.priority === 'urgent' ? 'bg-red-500' :
notification.priority === 'high' ? 'bg-orange-500' :
notification.priority === 'low' ? 'bg-gray-400' :
'bg-blue-500'
}`}></div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{notification.title}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatTimeAgo(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<div className="flex-shrink-0">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
</div>
)}
</div>
</div>
))
) : (
<div className="p-4 text-center text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('notifications.noNotifications')}</p>
<p className="text-xs mt-1">{t('notifications.placeholder')}</p>
</div>
)}
</div>
</div>
)}
@@ -227,8 +386,8 @@ const Navigation = () => {
</div>
</Link>
<div className="flex items-center space-x-2">
{/* Mobile Notifications - Admin only for now */}
{session?.user?.role === 'admin' && (
{/* Mobile Notifications - Admin and Team Lead */}
{(session?.user?.role === 'admin' || session?.user?.role === 'team_lead') && (
<button
onClick={() => {
setIsNotificationsOpen(!isNotificationsOpen);
@@ -241,7 +400,7 @@ const Navigation = () => {
</svg>
{/* Notification badge */}
<span className="absolute -top-1 -right-1 h-3 w-3 bg-gray-400 dark:bg-gray-500 text-white text-xs rounded-full flex items-center justify-center">
0
{unreadCount > 99 ? '99+' : unreadCount}
</span>
</button>
)}