Notifications
This commit is contained in:
101
src/api/notifications.ts
Normal file
101
src/api/notifications.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type NotificationType = 'NEGATIVE_FLOW' | 'SYSTEM_ALERT' | 'MAINTENANCE';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
user_id: string;
|
||||
meter_id: string | null;
|
||||
notification_type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
meter_serial_number: string | null;
|
||||
flow_value: number | null;
|
||||
is_read: boolean;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedNotifications {
|
||||
data: Notification[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
is_read?: boolean;
|
||||
notification_type?: NotificationType;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all notifications for the current user with optional filtering
|
||||
* @param filters - Optional filters for notifications
|
||||
* @returns Promise resolving to paginated notifications
|
||||
*/
|
||||
export async function fetchNotifications(filters?: NotificationFilters): Promise<PaginatedNotifications> {
|
||||
const params: Record<string, string | number | boolean> = {};
|
||||
|
||||
if (filters?.page !== undefined) params.page = filters.page;
|
||||
if (filters?.limit !== undefined) params.limit = filters.limit;
|
||||
if (filters?.is_read !== undefined) params.is_read = filters.is_read;
|
||||
if (filters?.notification_type) params.notification_type = filters.notification_type;
|
||||
if (filters?.start_date) params.start_date = filters.start_date;
|
||||
if (filters?.end_date) params.end_date = filters.end_date;
|
||||
|
||||
return apiClient.get<PaginatedNotifications>('/api/notifications', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of unread notifications
|
||||
* @returns Promise resolving to unread count
|
||||
*/
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const response = await apiClient.get<{ count: number }>('/api/notifications/unread-count');
|
||||
return response.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single notification by ID
|
||||
* @param id - The notification ID
|
||||
* @returns Promise resolving to the notification
|
||||
*/
|
||||
export async function fetchNotification(id: string): Promise<Notification> {
|
||||
return apiClient.get<Notification>(`/api/notifications/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read
|
||||
* @param id - The notification ID
|
||||
* @returns Promise resolving to the updated notification
|
||||
*/
|
||||
export async function markAsRead(id: string): Promise<Notification> {
|
||||
return apiClient.patch<Notification>(`/api/notifications/${id}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
* @returns Promise resolving to count of marked notifications
|
||||
*/
|
||||
export async function markAllAsRead(): Promise<number> {
|
||||
const response = await apiClient.patch<{ count: number }>('/api/notifications/read-all');
|
||||
return response.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
* @param id - The notification ID
|
||||
* @returns Promise resolving when the notification is deleted
|
||||
*/
|
||||
export async function deleteNotification(id: string): Promise<void> {
|
||||
return apiClient.delete<void>(`/api/notifications/${id}`);
|
||||
}
|
||||
257
src/components/NotificationDropdown.tsx
Normal file
257
src/components/NotificationDropdown.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* NotificationDropdown Component
|
||||
* Displays a dropdown with user notifications
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { X, Check, Trash2, AlertCircle } from 'lucide-react';
|
||||
import { useNotifications } from '../hooks/useNotifications';
|
||||
import type { Notification } from '../api/notifications';
|
||||
|
||||
interface NotificationDropdownProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
function formatTimeAgo(timestamp: string): string {
|
||||
const now = new Date();
|
||||
const created = new Date(timestamp);
|
||||
const diffMs = now.getTime() - created.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} minute${diffMins !== 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
|
||||
|
||||
return created.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Single notification item component
|
||||
*/
|
||||
const NotificationItem: React.FC<{
|
||||
notification: Notification;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}> = ({ notification, onMarkAsRead, onDelete }) => {
|
||||
const handleClick = () => {
|
||||
if (!notification.is_read) {
|
||||
onMarkAsRead(notification.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(notification.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-4 border-b border-gray-200 hover:bg-gray-50 transition cursor-pointer ${
|
||||
!notification.is_read ? 'bg-blue-50' : 'bg-white'
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 mt-1 ${
|
||||
notification.notification_type === 'NEGATIVE_FLOW' ? 'text-red-500' : 'text-blue-500'
|
||||
}`}>
|
||||
<AlertCircle size={20} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 truncate">
|
||||
{notification.title}
|
||||
</h4>
|
||||
|
||||
{!notification.is_read && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-blue-600 rounded-full mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{notification.flow_value !== null && (
|
||||
<p className="mt-1 text-xs text-red-600 font-medium">
|
||||
Flow value: {notification.flow_value} units
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="text-gray-400 hover:text-red-600 transition"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main NotificationDropdown component
|
||||
*/
|
||||
const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ isOpen, onClose }) => {
|
||||
const {
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
fetchNotifications,
|
||||
fetchMore,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
} = useNotifications();
|
||||
|
||||
// Fetch notifications when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [isOpen, fetchNotifications]);
|
||||
|
||||
// Close dropdown on Escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
try {
|
||||
await markAllAsRead();
|
||||
} catch (err) {
|
||||
console.error('Error marking all as read:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (id: string) => {
|
||||
try {
|
||||
await markAsRead(id);
|
||||
} catch (err) {
|
||||
console.error('Error marking as read:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteNotification(id);
|
||||
} catch (err) {
|
||||
console.error('Error deleting notification:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||
|
||||
return (
|
||||
<div className="absolute right-0 mt-2 w-96 rounded-xl bg-white border border-gray-200 shadow-xl overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<p className="text-xs text-gray-500">{unreadCount} unread</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium flex items-center gap-1"
|
||||
title="Mark all as read"
|
||||
>
|
||||
<Check size={14} />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition"
|
||||
title="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading && notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500 text-sm">
|
||||
Loading notifications...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="text-gray-400 mb-2">
|
||||
<AlertCircle size={32} className="mx-auto" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">No notifications</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
You're all caught up!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{notifications.map(notification => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasMore && (
|
||||
<div className="p-3 text-center border-t border-gray-200">
|
||||
<button
|
||||
onClick={fetchMore}
|
||||
disabled={loading}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDropdown;
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Bell, User, LogOut } from "lucide-react";
|
||||
import NotificationDropdown from "../NotificationDropdown";
|
||||
import { useNotifications } from "../../hooks/useNotifications";
|
||||
|
||||
interface TopMenuProps {
|
||||
page: string;
|
||||
@@ -29,7 +31,11 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
onRequestLogout,
|
||||
}) => {
|
||||
const [openUserMenu, setOpenUserMenu] = useState(false);
|
||||
const [openNotifications, setOpenNotifications] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const notificationRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const parts = (userName || "").trim().split(/\s+/).filter(Boolean);
|
||||
@@ -48,6 +54,16 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [openUserMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (!openNotifications) return;
|
||||
const el = notificationRef.current;
|
||||
if (el && !el.contains(e.target as Node)) setOpenNotifications(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [openNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpenUserMenu(false);
|
||||
@@ -81,13 +97,26 @@ const TopMenu: React.FC<TopMenuProps> = ({
|
||||
|
||||
{/* DERECHA */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
aria-label="Notificaciones"
|
||||
className="p-2 rounded-full hover:bg-white/10 transition"
|
||||
type="button"
|
||||
>
|
||||
<Bell size={20} />
|
||||
</button>
|
||||
<div className="relative" ref={notificationRef}>
|
||||
<button
|
||||
aria-label="Notificaciones"
|
||||
className="relative p-2 rounded-full hover:bg-white/10 transition"
|
||||
type="button"
|
||||
onClick={() => setOpenNotifications(!openNotifications)}
|
||||
>
|
||||
<Bell size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<NotificationDropdown
|
||||
isOpen={openNotifications}
|
||||
onClose={() => setOpenNotifications(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* USER MENU */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
|
||||
183
src/hooks/useNotifications.ts
Normal file
183
src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import * as notificationsApi from '../api/notifications';
|
||||
import type { Notification, NotificationFilters } from '../api/notifications';
|
||||
|
||||
interface UseNotificationsReturn {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
|
||||
fetchNotifications: (filters?: NotificationFilters) => Promise<void>;
|
||||
fetchMore: () => Promise<void>;
|
||||
refreshUnreadCount: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
deleteNotification: (id: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for managing notifications
|
||||
* @param autoRefreshInterval - Interval in milliseconds to auto-refresh unread count (default: 30000ms)
|
||||
* @returns Object with notifications data and methods
|
||||
*/
|
||||
export function useNotifications(autoRefreshInterval: number = 30000): UseNotificationsReturn {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(false);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
|
||||
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const fetchNotifications = useCallback(async (filters?: NotificationFilters) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await notificationsApi.fetchNotifications({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
...filters,
|
||||
});
|
||||
|
||||
setNotifications(response.data);
|
||||
setHasMore(response.pagination.hasNextPage);
|
||||
setPage(response.pagination.page);
|
||||
} catch (err) {
|
||||
console.error('Error fetching notifications:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch notifications');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (!hasMore || loading) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await notificationsApi.fetchNotifications({
|
||||
page: page + 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
setNotifications(prev => [...prev, ...response.data]);
|
||||
setHasMore(response.pagination.hasNextPage);
|
||||
setPage(response.pagination.page);
|
||||
} catch (err) {
|
||||
console.error('Error fetching more notifications:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch more notifications');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hasMore, loading, page]);
|
||||
|
||||
const refreshUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const count = await notificationsApi.getUnreadCount();
|
||||
setUnreadCount(count);
|
||||
} catch (err) {
|
||||
console.error('Error fetching unread count:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAsRead = useCallback(async (id: string) => {
|
||||
try {
|
||||
await notificationsApi.markAsRead(id);
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
notification.id === id
|
||||
? { ...notification, is_read: true, read_at: new Date().toISOString() }
|
||||
: notification
|
||||
)
|
||||
);
|
||||
|
||||
await refreshUnreadCount();
|
||||
} catch (err) {
|
||||
console.error('Error marking notification as read:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [refreshUnreadCount]);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
await notificationsApi.markAllAsRead();
|
||||
|
||||
setNotifications(prev =>
|
||||
prev.map(notification => ({
|
||||
...notification,
|
||||
is_read: true,
|
||||
read_at: new Date().toISOString(),
|
||||
}))
|
||||
);
|
||||
|
||||
setUnreadCount(0);
|
||||
} catch (err) {
|
||||
console.error('Error marking all notifications as read:', err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteNotification = useCallback(async (id: string) => {
|
||||
try {
|
||||
await notificationsApi.deleteNotification(id);
|
||||
|
||||
const deletedNotification = notifications.find(n => n.id === id);
|
||||
setNotifications(prev => prev.filter(notification => notification.id !== id));
|
||||
|
||||
if (deletedNotification && !deletedNotification.is_read) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting notification:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
fetchNotifications(),
|
||||
refreshUnreadCount(),
|
||||
]);
|
||||
}, [fetchNotifications, refreshUnreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshUnreadCount();
|
||||
|
||||
if (autoRefreshInterval > 0) {
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
refreshUnreadCount();
|
||||
}, autoRefreshInterval);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [autoRefreshInterval, refreshUnreadCount]);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
loading,
|
||||
error,
|
||||
hasMore,
|
||||
page,
|
||||
|
||||
fetchNotifications,
|
||||
fetchMore,
|
||||
refreshUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
deleteNotification,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user