/** * TASKMATE - List View Module * =========================== * Tabellarische Listenansicht der Aufgaben * Unterstützt gruppierte und flache Ansicht mit Inline-Bearbeitung */ import store from './store.js'; import api from './api.js'; import { $, $$, createElement, clearElement, formatDate, getDueDateStatus, filterTasks, getInitials, hexToRgba, getContrastColor, groupBy, sortBy, escapeHtml } from './utils.js'; class ListViewManager { constructor() { // DOM Elements this.container = null; this.contentElement = null; this.sortSelect = null; this.sortDirectionBtn = null; // State this.viewMode = 'grouped'; // 'grouped' | 'flat' this.sortColumn = 'dueDate'; this.sortDirection = 'asc'; this.collapsedGroups = new Set(); // Inline editing state this.editingCell = null; this.init(); } init() { this.container = $('#view-list'); this.contentElement = $('#list-content'); this.sortSelect = $('#list-sort-select'); this.sortDirectionBtn = $('#list-sort-direction'); if (!this.container) return; this.bindEvents(); // Subscribe to store changes for real-time updates store.subscribe('tasks', () => this.render()); store.subscribe('columns', () => this.render()); store.subscribe('filters', () => this.render()); store.subscribe('searchResultIds', () => this.render()); store.subscribe('users', () => this.render()); store.subscribe('labels', () => this.render()); store.subscribe('currentView', (view) => { if (view === 'list') this.render(); }); // Listen for app refresh events window.addEventListener('app:refresh', () => this.render()); } bindEvents() { // View mode toggle $$('.list-toggle-btn', this.container).forEach(btn => { btn.addEventListener('click', () => this.setViewMode(btn.dataset.mode)); }); // Sort select if (this.sortSelect) { this.sortSelect.addEventListener('change', () => { this.sortColumn = this.sortSelect.value; this.render(); }); } // Sort direction button if (this.sortDirectionBtn) { this.sortDirectionBtn.addEventListener('click', () => { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc'); this.render(); }); } // Delegate click events on content if (this.contentElement) { this.contentElement.addEventListener('click', (e) => this.handleContentClick(e)); this.contentElement.addEventListener('change', (e) => this.handleContentChange(e)); this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e)); // Stop editing when clicking outside document.addEventListener('click', (e) => { if (this.editingCell && !this.editingCell.contains(e.target)) { this.stopEditing(); } }); } } setViewMode(mode) { this.viewMode = mode; // Update toggle buttons $$('.list-toggle-btn', this.container).forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === mode); }); this.render(); } // ===================== // RENDERING // ===================== render() { if (!this.contentElement) return; if (store.get('currentView') !== 'list') return; const tasks = this.getFilteredAndSortedTasks(); if (tasks.length === 0) { this.renderEmpty(); return; } if (this.viewMode === 'grouped') { this.renderGrouped(tasks); } else { this.renderFlat(tasks); } } getFilteredAndSortedTasks() { const tasks = store.get('tasks').filter(t => !t.archived); const filters = store.get('filters'); const searchResultIds = store.get('searchResultIds') || []; const columns = store.get('columns'); // Apply filters let filtered = filterTasks(tasks, filters, searchResultIds, columns); // Apply sorting filtered = this.sortTasks(filtered); return filtered; } sortTasks(tasks) { const columns = store.get('columns'); const users = store.get('users'); return sortBy(tasks, (task) => { switch (this.sortColumn) { case 'title': return task.title?.toLowerCase() || ''; case 'priority': const priorityOrder = { high: 0, medium: 1, low: 2 }; return priorityOrder[task.priority] ?? 1; case 'dueDate': return task.dueDate ? new Date(task.dueDate).getTime() : Infinity; case 'status': const colIndex = columns.findIndex(c => c.id === task.columnId); return colIndex >= 0 ? colIndex : Infinity; case 'assignee': const user = users.find(u => u.id === task.assignedTo); return user?.displayName?.toLowerCase() || 'zzz'; default: return task.title?.toLowerCase() || ''; } }, this.sortDirection); } renderEmpty() { this.contentElement.innerHTML = `

Keine Aufgaben gefunden

Erstellen Sie eine neue Aufgabe oder ändern Sie die Filter.

`; } renderGrouped(tasks) { const columns = store.get('columns'); const tasksByColumn = groupBy(tasks, 'columnId'); clearElement(this.contentElement); columns.forEach(column => { const columnTasks = tasksByColumn[column.id] || []; if (columnTasks.length === 0) return; const isCollapsed = this.collapsedGroups.has(column.id); const group = createElement('div', { className: 'list-group' }); // Group header const header = createElement('div', { className: `list-group-header ${isCollapsed ? 'collapsed' : ''}`, dataset: { columnId: column.id } }); header.innerHTML = ` ${escapeHtml(column.name)} ${columnTasks.length} Aufgabe${columnTasks.length !== 1 ? 'n' : ''} `; header.addEventListener('click', () => this.toggleGroup(column.id)); group.appendChild(header); // Group content (table) const content = createElement('div', { className: `list-group-content ${isCollapsed ? 'collapsed' : ''}` }); // Table header content.appendChild(this.renderTableHeader()); // Table rows columnTasks.forEach(task => { content.appendChild(this.renderTableRow(task, column)); }); group.appendChild(content); this.contentElement.appendChild(group); }); } renderFlat(tasks) { clearElement(this.contentElement); const tableContainer = createElement('div', { className: 'list-table' }); // Table header tableContainer.appendChild(this.renderTableHeader()); // Table rows const columns = store.get('columns'); tasks.forEach(task => { const column = columns.find(c => c.id === task.columnId); tableContainer.appendChild(this.renderTableRow(task, column)); }); this.contentElement.appendChild(tableContainer); } renderTableHeader() { const header = createElement('div', { className: 'list-table-header' }); const columnDefs = [ { key: 'title', label: 'Aufgabe' }, { key: 'status', label: 'Status' }, { key: 'priority', label: 'Priorität' }, { key: 'dueDate', label: 'Fällig' }, { key: 'assignee', label: 'Zugewiesen' } ]; columnDefs.forEach(col => { const isSorted = this.sortColumn === col.key; const span = createElement('span', { className: isSorted ? `sorted ${this.sortDirection}` : '', dataset: { sortKey: col.key } }); span.innerHTML = ` ${col.label} `; span.addEventListener('click', () => { if (this.sortColumn === col.key) { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; } else { this.sortColumn = col.key; this.sortDirection = 'asc'; } if (this.sortSelect) this.sortSelect.value = this.sortColumn; if (this.sortDirectionBtn) { this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc'); } this.render(); }); header.appendChild(span); }); return header; } renderTableRow(task, column) { const row = createElement('div', { className: 'list-row', dataset: { taskId: task.id } }); // Title cell row.appendChild(this.renderTitleCell(task, column)); // Status cell row.appendChild(this.renderStatusCell(task, column)); // Priority cell row.appendChild(this.renderPriorityCell(task)); // Due date cell row.appendChild(this.renderDueDateCell(task)); // Assignee cell row.appendChild(this.renderAssigneeCell(task)); return row; } renderTitleCell(task, column) { const cell = createElement('div', { className: 'list-cell list-cell-title' }); // Color indicator const colorDot = createElement('span', { className: 'status-dot', style: { backgroundColor: column?.color || '#6366F1' } }); cell.appendChild(colorDot); // Title text (clickable to open task) const titleSpan = createElement('span', { dataset: { action: 'open-task', taskId: task.id } }, [escapeHtml(task.title)]); cell.appendChild(titleSpan); return cell; } renderStatusCell(task, column) { const columns = store.get('columns'); const cell = createElement('div', { className: 'list-cell list-cell-status list-cell-editable' }); // Status dot const dot = createElement('span', { className: 'status-dot', style: { backgroundColor: column?.color || '#6366F1' } }); cell.appendChild(dot); // Status dropdown const select = createElement('select', { dataset: { field: 'columnId', taskId: task.id } }); columns.forEach(col => { const option = createElement('option', { value: col.id, selected: col.id === task.columnId }, [col.name]); select.appendChild(option); }); cell.appendChild(select); return cell; } renderPriorityCell(task) { const cell = createElement('div', { className: `list-cell list-cell-priority ${task.priority || 'medium'} list-cell-editable` }); const select = createElement('select', { dataset: { field: 'priority', taskId: task.id } }); const priorities = [ { value: 'high', label: 'Hoch' }, { value: 'medium', label: 'Mittel' }, { value: 'low', label: 'Niedrig' } ]; priorities.forEach(p => { const option = createElement('option', { value: p.value, selected: p.value === (task.priority || 'medium') }, [p.label]); select.appendChild(option); }); cell.appendChild(select); return cell; } renderDueDateCell(task) { const status = getDueDateStatus(task.dueDate); let className = 'list-cell list-cell-date list-cell-editable'; if (status === 'overdue') className += ' overdue'; else if (status === 'today') className += ' today'; const cell = createElement('div', { className }); const input = createElement('input', { type: 'date', value: task.dueDate ? this.formatDateForInput(task.dueDate) : '', dataset: { field: 'dueDate', taskId: task.id } }); cell.appendChild(input); return cell; } renderAssigneeCell(task) { const users = store.get('users'); const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' }); // Sammle alle zugewiesenen Benutzer aus assignees Array const assignedUserIds = new Set(); // Verwende das assignees Array vom Backend if (task.assignees && Array.isArray(task.assignees)) { task.assignees.forEach(assignee => { if (assignee && assignee.id) { assignedUserIds.add(assignee.id); } }); } // Fallback: Füge assigned_to hinzu falls assignees leer ist if (assignedUserIds.size === 0 && task.assignedTo) { assignedUserIds.add(task.assignedTo); } // Container für mehrere Avatare const avatarContainer = createElement('div', { className: 'avatar-container' }); if (assignedUserIds.size > 0) { // Erstelle Avatar für jeden zugewiesenen Benutzer Array.from(assignedUserIds).forEach(userId => { const user = users.find(u => u.id === userId); if (user) { const avatar = createElement('div', { className: 'avatar', style: { backgroundColor: user.color || '#6366F1' }, title: user.displayName // Tooltip zeigt Name beim Hover }, [user.initials || getInitials(user.displayName)]); avatarContainer.appendChild(avatar); } }); } else { // Placeholder für "nicht zugewiesen" const placeholder = createElement('div', { className: 'avatar avatar-empty', title: 'Nicht zugewiesen' }, ['?']); avatarContainer.appendChild(placeholder); } cell.appendChild(avatarContainer); // User dropdown (versteckt, nur für Bearbeitung) const select = createElement('select', { className: 'assignee-select hidden', dataset: { field: 'assignedTo', taskId: task.id } }); // Empty option const emptyOption = createElement('option', { value: '' }, ['Nicht zugewiesen']); select.appendChild(emptyOption); users.forEach(user => { const option = createElement('option', { value: user.id, selected: user.id === task.assignedTo }, [user.displayName]); select.appendChild(option); }); cell.appendChild(select); return cell; } // ===================== // EVENT HANDLERS // ===================== handleContentClick(e) { // Handle avatar click for assignee editing if (e.target.classList.contains('avatar') || e.target.classList.contains('avatar-empty')) { const cell = e.target.closest('.list-cell-assignee'); if (cell) { this.startEditingAssignee(cell); return; } } // Handle click on avatar container (wenn man neben Avatar klickt) if (e.target.classList.contains('avatar-container')) { const cell = e.target.closest('.list-cell-assignee'); if (cell) { this.startEditingAssignee(cell); return; } } const target = e.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; const taskId = target.dataset.taskId; if (action === 'open-task' && taskId) { this.openTask(parseInt(taskId)); } } /** * Start editing assignee */ startEditingAssignee(cell) { // Stop any current editing this.stopEditing(); // Add editing class to show dropdown and hide avatar cell.classList.add('editing'); // Focus the select element const select = cell.querySelector('.assignee-select'); if (select) { select.focus(); } this.editingCell = cell; } /** * Stop editing */ stopEditing() { if (this.editingCell) { this.editingCell.classList.remove('editing'); this.editingCell = null; } } handleContentChange(e) { const target = e.target; const field = target.dataset.field; const taskId = target.dataset.taskId; if (field && taskId) { this.updateTaskField(parseInt(taskId), field, target.value); // Stop editing after change for assignee field if (field === 'assignedTo') { this.stopEditing(); } } } handleDoubleClick(e) { const titleCell = e.target.closest('.list-cell-title span[data-action="open-task"]'); if (titleCell) { const taskId = titleCell.dataset.taskId; if (taskId) { this.startInlineEdit(parseInt(taskId), titleCell); } } } toggleGroup(columnId) { if (this.collapsedGroups.has(columnId)) { this.collapsedGroups.delete(columnId); } else { this.collapsedGroups.add(columnId); } // Update DOM without full re-render const header = this.contentElement.querySelector(`.list-group-header[data-column-id="${columnId}"]`); const content = header?.nextElementSibling; if (header && content) { header.classList.toggle('collapsed'); content.classList.toggle('collapsed'); } } // ===================== // INLINE EDITING // ===================== startInlineEdit(taskId, element) { if (this.editingCell) { this.cancelInlineEdit(); } const task = store.get('tasks').find(t => t.id === taskId); if (!task) return; this.editingCell = { taskId, element, originalValue: task.title }; const input = createElement('input', { type: 'text', className: 'list-inline-input', value: task.title }); input.addEventListener('blur', () => this.finishInlineEdit()); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.finishInlineEdit(); } else if (e.key === 'Escape') { this.cancelInlineEdit(); } }); element.textContent = ''; element.appendChild(input); input.focus(); input.select(); } async finishInlineEdit() { if (!this.editingCell) return; const { taskId, element } = this.editingCell; const input = element.querySelector('input'); const newValue = input?.value?.trim(); if (newValue && newValue !== this.editingCell.originalValue) { await this.updateTaskField(taskId, 'title', newValue); } this.editingCell = null; this.render(); } cancelInlineEdit() { if (!this.editingCell) return; const { element, originalValue } = this.editingCell; element.textContent = originalValue; this.editingCell = null; } // ===================== // API OPERATIONS // ===================== async updateTaskField(taskId, field, value) { const projectId = store.get('currentProjectId'); if (!projectId) return; const task = store.get('tasks').find(t => t.id === taskId); if (!task) return; // Prepare update data let updateData = {}; if (field === 'columnId') { updateData.columnId = parseInt(value); } else if (field === 'assignedTo') { updateData.assignedTo = value ? parseInt(value) : null; } else if (field === 'dueDate') { updateData.dueDate = value || null; } else if (field === 'priority') { updateData.priority = value; } else if (field === 'title') { updateData.title = value; } // Optimistic update const tasks = store.get('tasks').map(t => { if (t.id === taskId) { return { ...t, ...updateData }; } return t; }); store.set('tasks', tasks); try { await api.updateTask(projectId, taskId, updateData); // Dispatch refresh event window.dispatchEvent(new CustomEvent('app:refresh')); } catch (error) { console.error('Fehler beim Aktualisieren der Aufgabe:', error); // Rollback on error const originalTasks = store.get('tasks').map(t => { if (t.id === taskId) { return task; } return t; }); store.set('tasks', originalTasks); // Show error notification window.dispatchEvent(new CustomEvent('toast:show', { detail: { type: 'error', message: 'Fehler beim Speichern der Änderung' } })); } } openTask(taskId) { const task = store.get('tasks').find(t => t.id === taskId); if (task) { window.dispatchEvent(new CustomEvent('task:edit', { detail: { task } })); } } // ===================== // UTILITIES // ===================== formatDateForInput(dateString) { if (!dateString) return ''; const date = new Date(dateString); // Use local date formatting (NOT toISOString!) const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } } // Create and export singleton instance const listViewManager = new ListViewManager(); export default listViewManager;