Seera-Unified-UI/asm_app/src/components/NotificationBell.tsx

279 lines
13 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { Bell } from 'lucide-react';
import { useNotifications } from '../hooks/useNotifications';
import { useNavigate } from 'react-router-dom';
import { FaCheck, FaTimes, FaBell } from 'react-icons/fa';
const stripHtml = (html: string): string => {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent || '').replace(/\uFEFF/g, '').replace(/\s+/g, ' ').trim();
};
const NotificationBell: React.FC = () => {
const { notifications, unreadCount, markAsRead, markAllAsRead, loading } = useNotifications();
const [isOpen, setIsOpen] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
// If notifications are not available (empty array and not loading),
// the bell will still show but with 0 count - this is fine
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleMarkAllAsRead = async () => {
setMarkingAll(true);
try {
await markAllAsRead();
} catch (error) {
console.warn('[NotificationBell] Could not mark all as read:', error);
} finally {
setMarkingAll(false);
}
};
const handleNotificationClick = async (notification: any) => {
console.log('[NotificationBell] Clicked notification:', notification);
console.log('[NotificationBell] document_type:', notification.document_type);
console.log('[NotificationBell] document_name:', notification.document_name);
// Try to mark as read, but don't block navigation if it fails
if (!notification.read) {
try {
await markAsRead(notification.name);
} catch (error) {
console.warn('[NotificationBell] Could not mark as read (permission issue):', error);
// Continue anyway - navigate to document
}
}
// Navigate based on document type
if (notification.document_type && notification.document_name) {
const docType = notification.document_type;
const docName = notification.document_name;
// Normalize document type (handle both spaces and underscores)
const normalizedType = docType.replace(/_/g, ' ').trim();
console.log('[NotificationBell] Normalized type:', normalizedType);
console.log('[NotificationBell] Document name:', docName);
// Map document types to routes
if (normalizedType === 'Asset Maintenance Log' || normalizedType === 'Asset Maintenance') {
console.log('[NotificationBell] Navigating to maintenance:', `/maintenance/${docName}`);
navigate(`/maintenance/${docName}`);
} else if (normalizedType === 'Work Order' || normalizedType === 'Asset Repair') {
console.log('[NotificationBell] Navigating to work order:', `/work-orders/${docName}`);
navigate(`/work-orders/${docName}`);
} else if (normalizedType === 'Asset') {
console.log('[NotificationBell] Navigating to asset:', `/assets/${docName}`);
navigate(`/assets/${docName}`);
} else if (normalizedType === 'PM Schedule Generator' || normalizedType === 'PM Schedule') {
console.log('[NotificationBell] Navigating to PPM planner:', `/ppm-planner/${docName}`);
navigate(`/ppm-planner/${docName}`);
} else if (normalizedType === 'PPM') {
console.log('[NotificationBell] Navigating to PPM:', `/ppm/${docName}`);
navigate(`/ppm/${docName}`);
} else if (normalizedType === 'Item') {
console.log('[NotificationBell] Navigating to inventory:', `/inventory/${docName}`);
navigate(`/inventory/${docName}`);
} else if (normalizedType === 'Inspection') {
console.log('[NotificationBell] Navigating to inspection:', `/inspections/${docName}`);
navigate(`/inspections/${docName}`);
} else if (normalizedType === 'Issue') {
console.log('[NotificationBell] Navigating to issue', `/issues/${docName}`);
navigate(`/support/${docName}`);
}else if (normalizedType === 'SFDA Entries') {
console.log('[NotificationBell] Navigating to SFDA Entries', `/sfda-entries/${docName}`);
navigate(`/sfda-entries/${docName}`);
}
else {
// Fallback: Try to open in Frappe if route not found
console.warn(`[NotificationBell] Unknown document type: ${docType}, opening in Frappe`);
const frappeRoute = docType.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');
window.open(`/app/${frappeRoute}/${docName}`, '_blank');
}
} else {
console.warn('[NotificationBell] No document_type or document_name found:', {
document_type: notification.document_type,
document_name: notification.document_name,
notification
});
}
setIsOpen(false);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.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}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
const unreadNotifications = notifications.filter(n => !n.read);
const readNotifications = notifications.filter(n => n.read).slice(0, 10);
return (
<div className="relative" ref={panelRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Notifications"
>
<Bell size={20} />
{unreadCount > 0 && (
<span className="absolute top-0 right-0 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-[9999] max-h-96 overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-gray-800 dark:text-white flex items-center gap-2">
<FaBell />
Notifications
{unreadCount > 0 && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full">
{unreadCount} new
</span>
)}
</h3>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
disabled={markingAll}
className={`text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline ${
markingAll ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{markingAll ? 'Marking...' : 'Mark all read'}
</button>
)}
</div>
{/* Notifications List */}
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
<FaBell className="mx-auto text-3xl mb-2 opacity-50" />
<p>No notifications</p>
</div>
) : (
<>
{unreadNotifications.length > 0 && (
<div className="p-2">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
NEW
</div>
{unreadNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-l-4 border-blue-500 bg-blue-50 dark:bg-blue-900/20"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{stripHtml((notif as any).subject || '') || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{stripHtml((notif as any).email_content || '')}
</p>
)}
{/* <p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)} */}
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
<div className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1"></div>
</div>
</div>
))}
</div>
)}
{readNotifications.length > 0 && (
<div className="p-2 border-t border-gray-200 dark:border-gray-700">
{unreadNotifications.length > 0 && (
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 px-2 mb-1">
EARLIER
</div>
)}
{readNotifications.map(notif => (
<div
key={notif.name}
onClick={() => handleNotificationClick(notif)}
className="p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{stripHtml((notif as any).subject || '') || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{stripHtml((notif as any).email_content || '')}
</p>
)}
{/* <p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{(notif as any).subject || notif.document_type || 'Notification'}
</p>
{(notif as any).email_content && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{(notif as any).email_content}
</p>
)} */}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{formatDate(notif.creation)}
</p>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
)}
</div>
);
};
export default NotificationBell;