454 lines
16 KiB
JavaScript
454 lines
16 KiB
JavaScript
/**
|
|
* Real-time Notifications JavaScript Component
|
|
* Handles notification polling, desktop notifications, and UI updates
|
|
*/
|
|
|
|
class NotificationManager {
|
|
constructor(options = {}) {
|
|
this.options = {
|
|
pollInterval: options.pollInterval || 30000, // 30 seconds
|
|
enableDesktopNotifications: options.enableDesktopNotifications || true,
|
|
enableSound: options.enableSound || false, // Disable sound by default to avoid 404
|
|
soundUrl: options.soundUrl || '/admin/sounds/notification.mp3',
|
|
markReadUrl: options.markReadUrl || '/admin/notifications/mark-read',
|
|
markAllReadUrl: options.markAllReadUrl || '/admin/notifications/mark-all-read',
|
|
fetchUrl: options.fetchUrl || '/admin/notifications/fetch',
|
|
...options
|
|
};
|
|
|
|
this.isPolling = false;
|
|
this.unreadCount = 0;
|
|
this.lastNotificationTime = null;
|
|
this.audio = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupElements();
|
|
this.requestDesktopPermission();
|
|
this.setupAudio();
|
|
this.bindEvents();
|
|
this.startPolling();
|
|
this.loadNotifications();
|
|
}
|
|
|
|
setupElements() {
|
|
this.notificationButton = document.getElementById('notification-button');
|
|
this.notificationDropdown = document.getElementById('notification-dropdown');
|
|
this.notificationCount = document.getElementById('notification-count');
|
|
this.notificationList = document.getElementById('notification-list');
|
|
this.markAllReadBtn = document.getElementById('mark-all-read');
|
|
|
|
if (!this.notificationButton || !this.notificationDropdown) {
|
|
console.warn('Notification elements not found');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
requestDesktopPermission() {
|
|
if (this.options.enableDesktopNotifications && 'Notification' in window) {
|
|
if (Notification.permission === 'default') {
|
|
Notification.requestPermission();
|
|
}
|
|
}
|
|
} setupAudio() {
|
|
if (this.options.enableSound) {
|
|
try {
|
|
this.audio = new Audio(this.options.soundUrl);
|
|
this.audio.preload = 'auto';
|
|
|
|
// Handle loading errors
|
|
this.audio.addEventListener('error', (e) => {
|
|
console.warn('Notification sound file not found or could not be loaded:', this.options.soundUrl);
|
|
this.audio = null; // Disable audio if it can't load
|
|
});
|
|
} catch (error) {
|
|
console.warn('Could not setup notification audio:', error);
|
|
this.audio = null;
|
|
}
|
|
}
|
|
} bindEvents() {
|
|
// Mark all as read
|
|
if (this.markAllReadBtn) {
|
|
this.markAllReadBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
this.markAllAsRead();
|
|
});
|
|
}
|
|
|
|
// Listen to Bootstrap dropdown events
|
|
if (this.notificationButton) {
|
|
// Try multiple event binding approaches for different Bootstrap versions
|
|
|
|
// Bootstrap 4/5 events
|
|
this.notificationButton.addEventListener('show.bs.dropdown', () => {
|
|
this.loadNotifications();
|
|
});
|
|
|
|
// jQuery-based Bootstrap events (fallback)
|
|
if (typeof $ !== 'undefined') {
|
|
$(this.notificationButton).on('show.bs.dropdown', () => {
|
|
this.loadNotifications();
|
|
});
|
|
|
|
// Also try the older Bootstrap 3 events
|
|
$(this.notificationButton).on('shown.bs.dropdown', () => {
|
|
this.loadNotifications();
|
|
});
|
|
}
|
|
|
|
// Fallback: still handle click events but don't prevent default
|
|
this.notificationButton.addEventListener('click', () => {
|
|
// Small delay to ensure dropdown state is updated
|
|
setTimeout(() => {
|
|
this.loadNotifications();
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
// Handle individual notification clicks
|
|
if (this.notificationList) {
|
|
this.notificationList.addEventListener('click', (e) => {
|
|
const notificationItem = e.target.closest('.notification-item');
|
|
if (notificationItem) {
|
|
const notificationId = notificationItem.dataset.notificationId;
|
|
if (notificationId) {
|
|
this.markAsRead(notificationId);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
startPolling() {
|
|
if (this.isPolling) return;
|
|
|
|
this.isPolling = true;
|
|
this.pollInterval = setInterval(() => {
|
|
this.loadNotifications();
|
|
}, this.options.pollInterval);
|
|
}
|
|
|
|
stopPolling() {
|
|
if (this.pollInterval) {
|
|
clearInterval(this.pollInterval);
|
|
this.pollInterval = null;
|
|
}
|
|
this.isPolling = false;
|
|
}
|
|
|
|
async loadNotifications() {
|
|
try {
|
|
const response = await fetch(this.options.fetchUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.updateNotifications(data);
|
|
} catch (error) {
|
|
console.error('Failed to load notifications:', error);
|
|
}
|
|
}
|
|
|
|
updateNotifications(data) {
|
|
const newUnreadCount = data.unread_count || 0;
|
|
const notifications = data.notifications || [];
|
|
|
|
// Check for new notifications
|
|
const hasNewNotifications = newUnreadCount > this.unreadCount;
|
|
|
|
// Update count
|
|
this.unreadCount = newUnreadCount;
|
|
this.updateUnreadCount();
|
|
|
|
// Update notification list
|
|
this.updateNotificationList(notifications);
|
|
|
|
// Show desktop notifications for new items
|
|
if (hasNewNotifications && notifications.length > 0) {
|
|
const latestNotification = notifications[0];
|
|
if (this.shouldShowDesktopNotification(latestNotification)) {
|
|
this.showDesktopNotification(latestNotification);
|
|
this.playSound();
|
|
}
|
|
}
|
|
}
|
|
|
|
updateUnreadCount() {
|
|
if (this.notificationCount) {
|
|
if (this.unreadCount > 0) {
|
|
// Format count: show 99+ for counts over 99
|
|
const displayCount = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
|
|
this.notificationCount.textContent = displayCount;
|
|
this.notificationCount.classList.remove('d-none');
|
|
this.notificationButton?.classList.add('has-notifications');
|
|
} else {
|
|
this.notificationCount.classList.add('d-none');
|
|
this.notificationButton?.classList.remove('has-notifications');
|
|
}
|
|
}
|
|
}
|
|
|
|
updateNotificationList(notifications) {
|
|
if (!this.notificationList) return;
|
|
|
|
if (notifications.length === 0) {
|
|
this.notificationList.innerHTML = `
|
|
<div class="dropdown-item text-center text-muted py-3">
|
|
<i class="fas fa-bell-slash mb-2"></i><br>
|
|
${trans('admin.NO_NOTIFICATIONS')}
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const notificationHtml = notifications.map(notification =>
|
|
this.renderNotification(notification)
|
|
).join('');
|
|
|
|
this.notificationList.innerHTML = notificationHtml;
|
|
}
|
|
|
|
renderNotification(notification) {
|
|
const isRead = notification.read_at;
|
|
const timeAgo = this.timeAgo(notification.created_at);
|
|
const iconClass = this.getNotificationIcon(notification.type, notification.data);
|
|
const typeClass = `type-${notification.type}`;
|
|
|
|
// Add special class for tardiness alerts
|
|
const isTardinessAlert = notification.type === 'system_alert' && notification.data && notification.data.type === 'tardiness_alert';
|
|
const specialClass = isTardinessAlert ? 'type-tardiness_alert' : typeClass;
|
|
|
|
return `
|
|
<div class="dropdown-item notification-item ${isRead ? 'read' : 'unread'}"
|
|
data-notification-id="${notification.id}">
|
|
<div class="d-flex align-items-start">
|
|
<div class="notification-icon ${specialClass} me-3">
|
|
<i class="${iconClass}"></i>
|
|
</div>
|
|
<div class="notification-content flex-grow-1">
|
|
<div class="notification-title">${this.escapeHtml(notification.title)}</div>
|
|
<div class="notification-message">${this.escapeHtml(notification.message)}</div>
|
|
<div class="notification-time text-muted small">
|
|
<i class="fas fa-clock"></i> ${timeAgo}
|
|
</div>
|
|
</div>
|
|
${!isRead ? '<div class="notification-badge"></div>' : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
getNotificationIcon(type, data = {}) {
|
|
// Check if it's a system_alert with specific subtype
|
|
if (type === 'system_alert' && data && data.type === 'tardiness_alert') {
|
|
return 'fas fa-clock text-danger';
|
|
}
|
|
|
|
const icons = {
|
|
'message': 'fas fa-envelope',
|
|
'system_alert': 'fas fa-exclamation-triangle',
|
|
'plugin_notification': 'fas fa-puzzle-piece',
|
|
'system': 'fas fa-cog',
|
|
'warning': 'fas fa-exclamation-triangle',
|
|
'info': 'fas fa-info-circle',
|
|
'success': 'fas fa-check-circle',
|
|
'error': 'fas fa-times-circle',
|
|
'tardiness_alert': 'fas fa-clock text-danger'
|
|
};
|
|
return icons[type] || 'fas fa-bell';
|
|
}
|
|
|
|
shouldShowDesktopNotification(notification) {
|
|
if (!this.options.enableDesktopNotifications || Notification.permission !== 'granted') {
|
|
return false;
|
|
}
|
|
|
|
// Don't show if notification is older than last check
|
|
if (this.lastNotificationTime && new Date(notification.created_at) <= this.lastNotificationTime) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
showDesktopNotification(notification) {
|
|
if (Notification.permission === 'granted') {
|
|
const desktopNotification = new Notification(notification.title, {
|
|
body: notification.message,
|
|
icon: '/admin/images/notification-icon.png',
|
|
tag: `notification-${notification.id}`
|
|
});
|
|
|
|
desktopNotification.onclick = () => {
|
|
window.focus();
|
|
this.markAsRead(notification.id);
|
|
desktopNotification.close();
|
|
};
|
|
|
|
// Auto close after 5 seconds
|
|
setTimeout(() => {
|
|
desktopNotification.close();
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
playSound() {
|
|
if (this.options.enableSound && this.audio) {
|
|
this.audio.play().catch(error => {
|
|
console.warn('Could not play notification sound:', error);
|
|
});
|
|
}
|
|
} async markAsRead(notificationId) {
|
|
try {
|
|
// Build the URL with the notification ID parameter
|
|
const url = this.options.markReadUrl.replace('__ID__', notificationId);
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Update UI to mark as read
|
|
const notificationItem = document.querySelector(`[data-notification-id="${notificationId}"]`);
|
|
if (notificationItem) {
|
|
notificationItem.classList.remove('unread');
|
|
notificationItem.classList.add('read');
|
|
const badge = notificationItem.querySelector('.notification-badge');
|
|
if (badge) badge.remove();
|
|
}
|
|
|
|
// Decrease unread count
|
|
if (this.unreadCount > 0) {
|
|
this.unreadCount--;
|
|
this.updateUnreadCount();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark notification as read:', error);
|
|
}
|
|
}
|
|
|
|
async markAllAsRead() {
|
|
try {
|
|
const response = await fetch(this.options.markAllReadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Update all notifications to read state
|
|
const unreadItems = document.querySelectorAll('.notification-item.unread');
|
|
unreadItems.forEach(item => {
|
|
item.classList.remove('unread');
|
|
item.classList.add('read');
|
|
const badge = item.querySelector('.notification-badge');
|
|
if (badge) badge.remove();
|
|
});
|
|
|
|
// Reset unread count
|
|
this.unreadCount = 0;
|
|
this.updateUnreadCount();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark all notifications as read:', error);
|
|
}
|
|
}
|
|
|
|
toggleDropdown() {
|
|
// Let Bootstrap handle the dropdown toggle naturally
|
|
// Just load notifications when needed
|
|
this.loadNotifications();
|
|
}
|
|
|
|
openDropdown() {
|
|
// Bootstrap handles the visual state, we just ensure data is loaded
|
|
this.loadNotifications();
|
|
}
|
|
|
|
closeDropdown() {
|
|
// Bootstrap handles the visual state
|
|
// No additional action needed
|
|
}
|
|
|
|
timeAgo(dateString) {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffInSeconds = Math.floor((now - date) / 1000);
|
|
|
|
if (diffInSeconds < 60) {
|
|
return trans('admin.JUST_NOW');
|
|
} else if (diffInSeconds < 3600) {
|
|
const minutes = Math.floor(diffInSeconds / 60);
|
|
return trans('admin.MINUTES_AGO').replace(':minutes', minutes);
|
|
} else if (diffInSeconds < 86400) {
|
|
const hours = Math.floor(diffInSeconds / 3600);
|
|
return trans('admin.HOURS_AGO').replace(':hours', hours);
|
|
} else {
|
|
const days = Math.floor(diffInSeconds / 86400);
|
|
return trans('admin.DAYS_AGO').replace(':days', days);
|
|
}
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
destroy() {
|
|
this.stopPolling();
|
|
// Remove event listeners if needed
|
|
}
|
|
}
|
|
|
|
// Global helper function for translations
|
|
function trans(key, replacements = {}) {
|
|
// This should be implemented based on your translation system
|
|
// For now, return the key as fallback
|
|
const translations = window.translations || {};
|
|
let translation = translations[key] || key;
|
|
|
|
Object.keys(replacements).forEach(search => {
|
|
translation = translation.replace(`:${search}`, replacements[search]);
|
|
});
|
|
|
|
return translation;
|
|
}
|
|
|
|
// Export for module systems
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = NotificationManager;
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Get configuration from backend (passed via window.notificationConfig)
|
|
const config = window.notificationConfig || {};
|
|
|
|
// Wait for NotificationManager to be available before initializing
|
|
window.notificationManager = new NotificationManager(config);
|
|
});
|