453 Zeilen
14 KiB
JavaScript
453 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;
|
|
|
|
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();
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
// Singleton-Instanz
|
|
const notificationManager = new NotificationManager();
|
|
|
|
export { notificationManager };
|
|
export default notificationManager;
|