267 lines
9 KiB
TypeScript
267 lines
9 KiB
TypeScript
/**
|
|
* NotificationBell Component
|
|
*
|
|
* Displays a bell icon with unread count badge.
|
|
* Clicking opens a dropdown with recent notifications.
|
|
*/
|
|
|
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { FaBell, FaCheck, FaTimes, FaEnvelope, FaCog, FaExclamationTriangle, FaCheckCircle } from 'react-icons/fa';
|
|
import { useNotifications, UserNotification } from '../../hooks/useNotifications';
|
|
import styles from './NotificationBell.module.css';
|
|
|
|
// Icon mapping for notification types
|
|
const typeIcons: Record<string, React.ReactNode> = {
|
|
invitation: <FaEnvelope />,
|
|
system: <FaCog />,
|
|
workflow: <FaCog />,
|
|
mention: <FaExclamationTriangle />
|
|
};
|
|
|
|
// Format timestamp to relative time (Unix seconds)
|
|
function formatRelativeTime(timestamp: number): string {
|
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
|
return '';
|
|
}
|
|
const now = Date.now() / 1000;
|
|
const diff = now - timestamp;
|
|
|
|
if (diff < 60) return 'Gerade eben';
|
|
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`;
|
|
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`;
|
|
if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`;
|
|
|
|
const date = new Date(timestamp * 1000);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
return date.toLocaleDateString('de-DE');
|
|
}
|
|
|
|
interface NotificationBellProps {
|
|
className?: string;
|
|
}
|
|
|
|
export const NotificationBell: React.FC<NotificationBellProps> = ({ className }) => {
|
|
const {
|
|
notifications,
|
|
unreadCount,
|
|
loading,
|
|
error,
|
|
fetchNotifications,
|
|
markAsRead,
|
|
markAllAsRead,
|
|
executeAction,
|
|
dismissNotification,
|
|
startPolling,
|
|
stopPolling
|
|
} = useNotifications();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Start polling on mount
|
|
useEffect(() => {
|
|
startPolling(30000); // Poll every 30 seconds
|
|
return () => stopPolling();
|
|
}, [startPolling, stopPolling]);
|
|
|
|
// Fetch notifications when dropdown opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
fetchNotifications({ limit: 10 });
|
|
}
|
|
}, [isOpen, fetchNotifications]);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Handle action button click
|
|
const handleAction = useCallback(async (
|
|
notification: UserNotification,
|
|
actionId: string,
|
|
event: React.MouseEvent
|
|
) => {
|
|
event.stopPropagation();
|
|
setActionLoading(`${notification.id}-${actionId}`);
|
|
|
|
const result = await executeAction(notification.id, actionId);
|
|
|
|
setActionLoading(null);
|
|
|
|
if (result) {
|
|
setActionSuccess(notification.id);
|
|
// Reload sidebar when accepting an invitation (grants new mandate/feature access)
|
|
if (actionId === 'accept' && notification.referenceType === 'Invitation') {
|
|
window.dispatchEvent(new CustomEvent('features-changed'));
|
|
}
|
|
// Clear success state after animation
|
|
setTimeout(() => {
|
|
setActionSuccess(null);
|
|
fetchNotifications({ limit: 10 });
|
|
}, 2000);
|
|
}
|
|
}, [executeAction, fetchNotifications]);
|
|
|
|
// Handle dismiss
|
|
const handleDismiss = useCallback(async (
|
|
notification: UserNotification,
|
|
event: React.MouseEvent
|
|
) => {
|
|
event.stopPropagation();
|
|
await dismissNotification(notification.id);
|
|
}, [dismissNotification]);
|
|
|
|
// Handle notification click (mark as read)
|
|
const handleNotificationClick = useCallback(async (notification: UserNotification) => {
|
|
if (notification.status === 'unread') {
|
|
await markAsRead(notification.id);
|
|
}
|
|
}, [markAsRead]);
|
|
|
|
// Filter out dismissed notifications
|
|
const visibleNotifications = notifications.filter(n => n.status !== 'dismissed');
|
|
|
|
return (
|
|
<div className={`${styles.notificationBell} ${className || ''}`} ref={dropdownRef}>
|
|
{/* Bell Button */}
|
|
<button
|
|
className={styles.bellButton}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
aria-label={`Benachrichtigungen ${unreadCount > 0 ? `(${unreadCount} ungelesen)` : ''}`}
|
|
>
|
|
<FaBell className={styles.bellIcon} />
|
|
{unreadCount > 0 && (
|
|
<span className={styles.badge}>
|
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* Dropdown */}
|
|
{isOpen && (
|
|
<div className={styles.dropdown}>
|
|
{/* Header */}
|
|
<div className={styles.header}>
|
|
<h3>Benachrichtigungen</h3>
|
|
{visibleNotifications.some(n => n.status === 'unread') && (
|
|
<button
|
|
className={styles.markAllRead}
|
|
onClick={() => markAllAsRead()}
|
|
>
|
|
Alle als gelesen markieren
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className={styles.content}>
|
|
{loading && visibleNotifications.length === 0 && (
|
|
<div className={styles.loading}>Lade...</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className={styles.error}>{error}</div>
|
|
)}
|
|
|
|
{!loading && !error && visibleNotifications.length === 0 && (
|
|
<div className={styles.empty}>
|
|
<FaBell className={styles.emptyIcon} />
|
|
<p>Keine Benachrichtigungen</p>
|
|
</div>
|
|
)}
|
|
|
|
{visibleNotifications.map(notification => (
|
|
<div
|
|
key={notification.id}
|
|
className={`
|
|
${styles.notification}
|
|
${notification.status === 'unread' ? styles.unread : ''}
|
|
${actionSuccess === notification.id ? styles.success : ''}
|
|
`}
|
|
onClick={() => handleNotificationClick(notification)}
|
|
>
|
|
{/* Success overlay */}
|
|
{actionSuccess === notification.id && (
|
|
<div className={styles.successOverlay}>
|
|
<FaCheckCircle />
|
|
<span>{notification.actionResult || 'Erfolgreich'}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Icon */}
|
|
<div className={`${styles.icon} ${styles[`icon_${notification.type}`]}`}>
|
|
{typeIcons[notification.type] || <FaBell />}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className={styles.notificationContent}>
|
|
<div className={styles.title}>{notification.title}</div>
|
|
<div className={styles.message}>{notification.message}</div>
|
|
<div className={styles.time}>{formatRelativeTime(notification.createdAt)}</div>
|
|
|
|
{/* Actions */}
|
|
{notification.actions && notification.status !== 'actioned' && (
|
|
<div className={styles.actions}>
|
|
{notification.actions.map(action => (
|
|
<button
|
|
key={action.actionId}
|
|
className={`${styles.actionButton} ${styles[`action_${action.style}`]}`}
|
|
onClick={(e) => handleAction(notification, action.actionId, e)}
|
|
disabled={actionLoading === `${notification.id}-${action.actionId}`}
|
|
>
|
|
{actionLoading === `${notification.id}-${action.actionId}` ? (
|
|
'...'
|
|
) : action.actionId === 'accept' ? (
|
|
<><FaCheck /> {action.label}</>
|
|
) : action.actionId === 'decline' ? (
|
|
<><FaTimes /> {action.label}</>
|
|
) : (
|
|
action.label
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action result */}
|
|
{notification.actionTaken && (
|
|
<div className={styles.actionResult}>
|
|
{notification.actionResult}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Dismiss button */}
|
|
{notification.status !== 'actioned' && (
|
|
<button
|
|
className={styles.dismissButton}
|
|
onClick={(e) => handleDismiss(notification, e)}
|
|
aria-label="Schliessen"
|
|
>
|
|
<FaTimes />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NotificationBell;
|