feat: implement notifications system with API endpoints for fetching and marking notifications as read
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user