Update
This commit is contained in:
453
public/admin/assets/js/messaging/notifications.js
Normal file
453
public/admin/assets/js/messaging/notifications.js
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user