Initial commit
Dieser Commit ist enthalten in:
452
frontend/js/notifications.js
Normale Datei
452
frontend/js/notifications.js
Normale Datei
@ -0,0 +1,452 @@
|
||||
/**
|
||||
* 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;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren