Notifications

This commit is contained in:
2026-02-01 20:54:13 -06:00
parent 6c02bd5448
commit 48e0884bf7
14 changed files with 1628 additions and 7 deletions

View 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;

View File

@@ -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}>