Files
TaskMate/frontend/js/notifications.js
2026-01-04 00:24:11 +00:00

466 Zeilen
14 KiB
JavaScript

/**
* TASKMATE - Notification Manager
* ================================
* Frontend-Verwaltung für das Benachrichtigungssystem
*/
import api from './api.js';
import store from './store.js';
class NotificationManager {
constructor() {
this.notifications = [];
this.unreadCount = 0;
this.isDropdownOpen = false;
this.initialized = false;
}
/**
* Initialisierung
*/
async init() {
if (this.initialized) return;
this.bindElements();
this.bindEvents();
await this.loadNotifications();
this.initialized = true;
}
/**
* DOM-Elemente binden
*/
bindElements() {
this.bellBtn = document.getElementById('notification-btn');
this.badge = document.getElementById('notification-badge');
this.dropdown = document.getElementById('notification-dropdown');
this.list = document.getElementById('notification-list');
this.emptyState = document.getElementById('notification-empty');
this.markAllBtn = document.getElementById('btn-mark-all-read');
this.bellContainer = document.getElementById('notification-bell');
}
/**
* Event-Listener binden
*/
bindEvents() {
// Toggle Dropdown
this.bellBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleDropdown();
});
// Klick außerhalb schließt Dropdown
document.addEventListener('click', (e) => {
if (!this.bellContainer?.contains(e.target)) {
this.closeDropdown();
}
});
// Alle als gelesen markieren
this.markAllBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.markAllAsRead();
});
// Klicks in der Liste
this.list?.addEventListener('click', (e) => {
const deleteBtn = e.target.closest('.notification-delete');
if (deleteBtn) {
e.stopPropagation();
const id = parseInt(deleteBtn.dataset.delete);
this.deleteNotification(id);
return;
}
const item = e.target.closest('.notification-item');
if (item) {
this.handleItemClick(item);
}
});
// WebSocket Events
window.addEventListener('notification:new', (e) => {
this.addNotification(e.detail.notification);
});
window.addEventListener('notification:count', (e) => {
this.updateBadge(e.detail.count);
});
window.addEventListener('notification:deleted', (e) => {
this.removeNotification(e.detail.notificationId);
});
}
/**
* Benachrichtigungen vom Server laden
*/
async loadNotifications() {
try {
const data = await api.getNotifications();
this.notifications = data.notifications || [];
this.unreadCount = data.unreadCount || 0;
this.render();
} catch (error) {
console.error('Fehler beim Laden der Benachrichtigungen:', error);
}
}
/**
* Alles rendern
*/
render() {
this.updateBadge(this.unreadCount);
this.renderList();
}
/**
* Badge aktualisieren
*/
updateBadge(count) {
this.unreadCount = count;
// Sicherstellen, dass badge-Element existiert
if (!this.badge) {
this.badge = document.getElementById('notification-badge');
}
if (!this.badge) return; // Wenn immer noch nicht gefunden, abbrechen
if (count > 0) {
this.badge.textContent = count > 99 ? '99+' : count;
this.badge.classList.remove('hidden');
this.bellContainer?.classList.add('has-notifications');
} else {
this.badge.classList.add('hidden');
this.bellContainer?.classList.remove('has-notifications');
}
}
/**
* Liste rendern
*/
renderList() {
if (!this.notifications || this.notifications.length === 0) {
this.list?.classList.add('hidden');
this.emptyState?.classList.remove('hidden');
return;
}
this.list?.classList.remove('hidden');
this.emptyState?.classList.add('hidden');
this.list.innerHTML = this.notifications.map(n => this.renderItem(n)).join('');
}
/**
* Einzelnes Item rendern
*/
renderItem(notification) {
const timeAgo = this.formatTimeAgo(notification.createdAt);
const iconClass = this.getIconClass(notification.type);
const icon = this.getIcon(notification.type);
return `
<div class="notification-item ${notification.isRead ? '' : 'unread'} ${notification.isPersistent ? 'persistent' : ''}"
data-id="${notification.id}"
data-task-id="${notification.taskId || ''}"
data-proposal-id="${notification.proposalId || ''}">
<div class="notification-type-icon ${iconClass}">
${icon}
</div>
<div class="notification-content">
<div class="notification-title">${this.escapeHtml(notification.title)}</div>
${notification.message ? `<div class="notification-message">${this.escapeHtml(notification.message)}</div>` : ''}
<div class="notification-time">${timeAgo}</div>
</div>
${!notification.isPersistent ? `
<div class="notification-actions">
<button class="notification-delete" title="Löschen" data-delete="${notification.id}">
<svg viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
` : ''}
</div>
`;
}
/**
* Icon-Klasse basierend auf Typ
*/
getIconClass(type) {
if (type.startsWith('task:assigned') || type.startsWith('task:unassigned') || type.startsWith('task:due')) {
return 'task';
}
if (type.startsWith('task:completed')) {
return 'completed';
}
if (type.startsWith('task:priority')) {
return 'priority';
}
if (type.startsWith('comment:mention')) {
return 'mention';
}
if (type.startsWith('comment:')) {
return 'comment';
}
if (type.startsWith('approval:')) {
return 'approval';
}
return 'task';
}
/**
* Icon SVG basierend auf Typ
*/
getIcon(type) {
if (type.startsWith('task:assigned')) {
return '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M20 8v6M23 11h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
}
if (type.startsWith('task:unassigned')) {
return '<svg viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="8.5" cy="7" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M23 11h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
}
if (type.startsWith('task:completed')) {
return '<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 4 12 14.01l-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
if (type.startsWith('task:due')) {
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
}
if (type.startsWith('task:priority')) {
return '<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
}
if (type.startsWith('comment:mention')) {
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
}
if (type.startsWith('comment:')) {
return '<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
}
if (type.startsWith('approval:pending')) {
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
}
if (type.startsWith('approval:granted')) {
return '<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" stroke="currentColor" stroke-width="2" fill="none"/><path d="M22 4 12 14.01l-3-3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
if (type.startsWith('approval:rejected')) {
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
}
// Default
return '<svg viewBox="0 0 24 24"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
}
/**
* Zeit-Formatierung
*/
formatTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `Vor ${diffMins} Minute${diffMins === 1 ? '' : 'n'}`;
if (diffHours < 24) return `Vor ${diffHours} Stunde${diffHours === 1 ? '' : 'n'}`;
if (diffDays < 7) return `Vor ${diffDays} Tag${diffDays === 1 ? '' : 'en'}`;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/**
* HTML escapen
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Dropdown öffnen/schließen
*/
toggleDropdown() {
if (this.isDropdownOpen) {
this.closeDropdown();
} else {
this.openDropdown();
}
}
openDropdown() {
this.dropdown?.classList.remove('hidden');
this.isDropdownOpen = true;
// Ungelesene als gelesen markieren wenn Dropdown geöffnet
this.markVisibleAsRead();
}
closeDropdown() {
this.dropdown?.classList.add('hidden');
this.isDropdownOpen = false;
}
/**
* Sichtbare Benachrichtigungen als gelesen markieren
*/
async markVisibleAsRead() {
const unreadItems = this.notifications.filter(n => !n.isRead && !n.isPersistent);
for (const notification of unreadItems) {
try {
await api.markNotificationRead(notification.id);
notification.isRead = true;
} catch (error) {
console.error('Fehler beim Markieren als gelesen:', error);
}
}
this.renderList();
}
/**
* Alle als gelesen markieren
*/
async markAllAsRead() {
try {
await api.markAllNotificationsRead();
this.notifications.forEach(n => {
if (!n.isPersistent) n.isRead = true;
});
this.unreadCount = this.notifications.filter(n => !n.isRead).length;
this.updateBadge(this.unreadCount);
this.renderList();
} catch (error) {
console.error('Fehler beim Markieren aller als gelesen:', error);
}
}
/**
* Benachrichtigung löschen
*/
async deleteNotification(id) {
try {
const result = await api.deleteNotification(id);
this.notifications = this.notifications.filter(n => n.id !== id);
this.unreadCount = result.unreadCount;
this.updateBadge(this.unreadCount);
this.renderList();
} catch (error) {
console.error('Fehler beim Löschen der Benachrichtigung:', error);
}
}
/**
* Neue Benachrichtigung hinzufügen
*/
addNotification(notification) {
// Am Anfang der Liste hinzufügen
this.notifications.unshift(notification);
this.unreadCount++;
this.updateBadge(this.unreadCount);
this.renderList();
// Toast anzeigen wenn Dropdown geschlossen
if (!this.isDropdownOpen) {
this.showToast(notification.title, notification.message);
}
}
/**
* Benachrichtigung entfernen (WebSocket)
*/
removeNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
this.renderList();
}
/**
* Toast-Benachrichtigung anzeigen
*/
showToast(title, message) {
// Einfache Toast-Implementation
const toast = document.createElement('div');
toast.className = 'notification-toast';
toast.innerHTML = `
<strong>${this.escapeHtml(title)}</strong>
${message ? `<p>${this.escapeHtml(message)}</p>` : ''}
`;
toast.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
padding: 16px 20px;
background: var(--bg-card, #fff);
border: 1px solid var(--border-default, #e2e8f0);
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
z-index: 9999;
max-width: 320px;
animation: slideIn 0.3s ease;
`;
// Animation
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(style);
document.body.appendChild(toast);
// Nach 4 Sekunden entfernen
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => {
toast.remove();
style.remove();
}, 300);
}, 4000);
}
/**
* Item-Klick behandeln
*/
handleItemClick(item) {
const taskId = item.dataset.taskId;
const proposalId = item.dataset.proposalId;
if (taskId) {
// Zur Aufgabe navigieren
this.closeDropdown();
window.dispatchEvent(new CustomEvent('notification:open-task', { detail: { taskId: parseInt(taskId) } }));
} else if (proposalId) {
// Zum Genehmigung-Tab wechseln
this.closeDropdown();
window.dispatchEvent(new CustomEvent('notification:open-proposal', { detail: { proposalId: parseInt(proposalId) } }));
}
}
/**
* Reset
*/
reset() {
this.notifications = [];
this.unreadCount = 0;
this.isDropdownOpen = false;
this.closeDropdown();
// Elements neu binden falls nötig
if (!this.badge || !this.bellContainer) {
this.bindElements();
}
this.render();
}
}
// Singleton-Instanz
const notificationManager = new NotificationManager();
export { notificationManager };
export default notificationManager;