/** * TASKMATE - Board Module * ======================= * Kanban board functionality */ import store from './store.js'; import api from './api.js'; import syncManager from './sync.js'; import offlineManager from './offline.js'; import { $, $$, createElement, clearElement, formatDate, getDueDateStatus, calculateProgress, filterTasks, getInitials, hexToRgba, getContrastColor, generateTempId, debounce, createPriorityElement } from './utils.js'; class BoardManager { constructor() { this.boardElement = null; this.draggedTask = null; this.draggedColumn = null; this.dropTarget = null; // Week strip calendar this.weekStripDate = this.getMonday(new Date()); this.tooltip = null; // Layout preferences this.multiColumnLayout = this.loadLayoutPreference(); this.init(); } init() { this.boardElement = $('#board'); this.bindEvents(); this.bindWeekStripEvents(); this.renderWeekStrip(); // Subscribe to store changes store.subscribe('columns', () => { this.render(); this.renderWeekStrip(); // Update week strip when column colors change }); store.subscribe('tasks', () => { this.renderTasks(); this.updateStats(); this.renderWeekStrip(); }); store.subscribe('filters', () => this.renderTasks()); store.subscribe('searchResultIds', () => this.renderTasks()); store.subscribe('labels', () => this.renderTasks()); store.subscribe('currentProjectId', () => { this.loadStats(); this.renderWeekStrip(); }); // Apply initial layout preference setTimeout(() => { this.applyLayoutClass(); if (this.multiColumnLayout) { this.checkAndApplyDynamicLayout(); } }, 100); // Re-check layout on window resize window.addEventListener('resize', debounce(() => { if (this.multiColumnLayout) { this.checkAndApplyDynamicLayout(); } }, 250)); } // ===================== // STATS // ===================== async loadStats() { const projectId = store.get('currentProjectId'); if (!projectId) return; try { const stats = await api.getStats(projectId); this.renderStats(stats); } catch (error) { console.error('Failed to load stats:', error); } } renderStats(stats) { const openEl = $('#board-stat-open'); const progressEl = $('#board-stat-progress'); const doneEl = $('#board-stat-done'); const overdueEl = $('#board-stat-overdue'); if (openEl) openEl.textContent = stats.open || 0; if (progressEl) progressEl.textContent = stats.inProgress || 0; if (doneEl) doneEl.textContent = stats.completed || 0; if (overdueEl) overdueEl.textContent = stats.overdue || 0; } updateStats() { // Calculate stats from current tasks in store const tasks = store.get('tasks').filter(t => !t.archived); const columns = store.get('columns'); // Find column positions to determine status const columnMap = {}; columns.forEach((col, idx) => { columnMap[col.id] = idx; }); let open = 0; let inProgress = 0; let completed = 0; let overdue = 0; const today = new Date(); today.setHours(0, 0, 0, 0); tasks.forEach(task => { const colIdx = columnMap[task.columnId]; const totalCols = columns.length; // First column = open, last column = done, middle = in progress if (totalCols > 0) { if (colIdx === 0) { open++; } else if (colIdx === totalCols - 1) { completed++; } else { inProgress++; } } // Check overdue (only for non-completed tasks) if (task.dueDate && colIdx !== totalCols - 1) { const dueDate = new Date(task.dueDate); dueDate.setHours(0, 0, 0, 0); if (dueDate < today) { overdue++; } } }); this.renderStats({ open, inProgress, completed, overdue }); } bindEvents() { // Note: Add column button is rendered dynamically with its own onclick handler // Delegated event handling for board this.boardElement?.addEventListener('click', (e) => this.handleBoardClick(e)); // Drag and drop for tasks this.boardElement?.addEventListener('dragstart', (e) => this.handleDragStart(e)); this.boardElement?.addEventListener('dragend', (e) => this.handleDragEnd(e)); this.boardElement?.addEventListener('dragover', (e) => this.handleDragOver(e)); this.boardElement?.addEventListener('dragleave', (e) => this.handleDragLeave(e)); this.boardElement?.addEventListener('drop', (e) => this.handleDrop(e)); // Keyboard navigation document.addEventListener('keydown', (e) => this.handleKeyboard(e)); // Layout toggle button - use delegated event handling document.addEventListener('click', (e) => { if (e.target.closest('#btn-toggle-layout')) { this.toggleLayout(); } }); } // ===================== // RENDERING // ===================== render() { if (!this.boardElement) return; const columns = store.get('columns'); clearElement(this.boardElement); // Apply layout class this.applyLayoutClass(); columns.forEach(column => { const columnElement = this.createColumnElement(column); this.boardElement.appendChild(columnElement); }); // Add "Add Column" button const addColumnBtn = createElement('button', { className: 'btn-add-column', onclick: () => this.openAddColumnModal() }, [ createElement('span', { className: 'icon' }, ['+']), 'Statuskarte hinzufügen' ]); this.boardElement.appendChild(addColumnBtn); // Check dynamic layout after render setTimeout(() => this.checkAndApplyDynamicLayout(), 100); } createColumnElement(column) { const tasks = store.getTasksByColumn(column.id); const columns = store.get('columns'); const filteredTasks = filterTasks(tasks, store.get('filters'), store.get('searchResultIds'), columns); const columnEl = createElement('div', { className: 'column', dataset: { columnId: column.id } }); // Header with column color - draggable const header = createElement('div', { className: 'column-header', draggable: 'true' }); // Apply column color to header background const columnColor = column.color || '#6B7280'; header.style.backgroundColor = columnColor; header.style.color = getContrastColor(columnColor); const title = createElement('div', { className: 'column-title' }, [ createElement('span', {}, [column.name]), createElement('span', { className: 'column-count' }, [filteredTasks.length.toString()]) ]); const actions = createElement('div', { className: 'column-actions' }, [ createElement('button', { className: 'btn btn-icon btn-ghost', title: 'Spalte bearbeiten', dataset: { action: 'edit-column', columnId: column.id } }, [this.createIcon('edit')]), createElement('button', { className: 'btn btn-icon btn-ghost', title: 'Spalte löschen', dataset: { action: 'delete-column', columnId: column.id } }, [this.createIcon('trash')]) ]); header.appendChild(title); header.appendChild(actions); // Body (task list) const body = createElement('div', { className: 'column-body', dataset: { columnId: column.id } }); filteredTasks.forEach(task => { body.appendChild(this.createTaskCard(task)); }); // Footer (add task button) const footer = createElement('div', { className: 'column-footer' }, [ createElement('button', { className: 'btn-add-task', dataset: { action: 'add-task', columnId: column.id } }, [ createElement('span', {}, ['+']), 'Aufgabe hinzufügen' ]) ]); columnEl.appendChild(header); columnEl.appendChild(body); columnEl.appendChild(footer); return columnEl; } createTaskCard(task) { const dueStatus = getDueDateStatus(task.dueDate); const progress = calculateProgress(task.subtasks); const isCompleted = this.isTaskCompleted(task); const classes = ['task-card']; if (store.get('selectedTaskIds').includes(task.id)) classes.push('selected'); if (!isCompleted && dueStatus === 'overdue') classes.push('overdue'); if (!isCompleted && (dueStatus === 'soon' || dueStatus === 'today')) classes.push('due-soon'); const card = createElement('div', { className: classes.join(' '), dataset: { taskId: task.id }, draggable: 'true' }); // Header with title and priority const header = createElement('div', { className: 'task-card-header' }, [ createElement('span', { className: 'task-title' }, [task.title]), createPriorityElement(task.priority) ]); card.appendChild(header); // Labels if (task.labels && task.labels.length > 0) { const labelsContainer = createElement('div', { className: 'task-card-labels' }); task.labels.forEach(label => { labelsContainer.appendChild(createElement('span', { className: 'task-label', style: { backgroundColor: hexToRgba(label.color, 0.2), color: label.color } }, [label.name])); }); card.appendChild(labelsContainer); } // Meta info (due date, time estimate) const metaItems = []; if (task.dueDate) { const dueDateClass = (!isCompleted && dueStatus === 'overdue') ? 'text-error' : ''; // Show relative date for non-completed tasks, absolute date for completed const dateDisplay = isCompleted ? formatDate(task.dueDate, { relative: false }) : formatDate(task.dueDate, { relative: true }); metaItems.push(createElement('span', { className: `task-meta-item ${dueDateClass}` }, [ this.createIcon('calendar'), dateDisplay ])); } if (task.timeEstimateMin) { const hours = Math.floor(task.timeEstimateMin / 60); const minutes = task.timeEstimateMin % 60; metaItems.push(createElement('span', { className: 'task-meta-item' }, [ this.createIcon('clock'), this.formatEstimate(hours, minutes) ])); } if (metaItems.length > 0) { const meta = createElement('div', { className: 'task-card-meta' }, metaItems); card.appendChild(meta); } // Progress bar for subtasks if (progress) { const progressContainer = createElement('div', { className: 'task-card-progress' }); const progressBar = createElement('div', { className: 'task-progress-bar' }, [ createElement('div', { className: 'task-progress-fill', style: { width: `${progress.percentage}%` } }) ]); const progressText = createElement('span', { className: 'task-progress-text' }, [ `${progress.completed}/${progress.total} Unteraufgaben` ]); progressContainer.appendChild(progressBar); progressContainer.appendChild(progressText); card.appendChild(progressContainer); } // Verknüpfte Genehmigungen anzeigen if (task.proposals && task.proposals.length > 0) { const proposalsContainer = createElement('div', { className: 'task-card-proposals' }); task.proposals.forEach(proposal => { const proposalItem = createElement('div', { className: `task-proposal-item ${proposal.approved ? 'approved' : 'pending'}` }, [ createElement('span', { className: 'task-proposal-icon' }, [ proposal.approved ? '✓' : '○' ]), createElement('span', { className: 'task-proposal-title' }, [proposal.title]) ]); proposalsContainer.appendChild(proposalItem); }); card.appendChild(proposalsContainer); } // Footer with assignees and counts const hasAssignees = task.assignees && task.assignees.length > 0; const hasFooterContent = hasAssignees || task.assignedTo || task.commentCount > 0 || task.attachmentCount > 0; if (hasFooterContent) { const footer = createElement('div', { className: 'task-card-footer' }); // Assignees (Mehrfachzuweisung) if (hasAssignees) { const users = store.get('users'); const assigneesContainer = createElement('div', { className: 'task-assignees' }); // Avatare fuer alle zugewiesenen Benutzer anzeigen const maxVisible = 3; // Maximal 3 Avatare anzeigen const visibleAssignees = task.assignees.slice(0, maxVisible); const hiddenCount = task.assignees.length - maxVisible; visibleAssignees.forEach((assignee, index) => { // Aktuellen Benutzer aus Store holen (fuer aktuelle Farbe) const currentUser = users.find(u => u.id === assignee.id); const color = currentUser?.color || assignee.color || '#888'; const name = currentUser?.display_name || assignee.display_name || assignee.username || 'Benutzer'; // Initialen berechnen - currentUser hat immer die korrekten initials let initials = currentUser?.initials || getInitials(name) || '?'; // Sicherheit: Falls initials undefined ist, Fallback verwenden if (!initials || initials === 'undefined') { initials = getInitials(currentUser?.email || name) || '?'; } const avatar = createElement('span', { className: 'avatar task-assignee-avatar stacked', style: { backgroundColor: color, zIndex: 10 - index }, title: name }, [initials]); assigneesContainer.appendChild(avatar); }); // "+X" Anzeige wenn mehr als maxVisible Benutzer if (hiddenCount > 0) { const moreIndicator = createElement('span', { className: 'avatar task-assignee-avatar stacked more-indicator', style: { zIndex: 1 }, title: `${hiddenCount} weitere Mitarbeitende` }, [`+${hiddenCount}`]); assigneesContainer.appendChild(moreIndicator); } footer.appendChild(assigneesContainer); } else if (task.assignedTo) { // Fallback fuer alte Einzelzuweisung (Abwaertskompatibilitaet) const users = store.get('users'); const assignedUser = users.find(u => u.id === task.assignedTo); const currentColor = assignedUser?.color || task.assignedColor || '#888'; const currentName = assignedUser?.display_name || assignedUser?.username || task.assignedName || 'Benutzer'; // Initialen berechnen let initials = assignedUser?.initials || getInitials(currentName) || '?'; if (!initials || initials === 'undefined') { initials = getInitials(assignedUser?.email || currentName) || '?'; } const assignee = createElement('div', { className: 'task-assignees' }, [ createElement('span', { className: 'avatar task-assignee-avatar', style: { backgroundColor: currentColor }, title: currentName }, [initials]) ]); footer.appendChild(assignee); } else { footer.appendChild(createElement('div')); } // Counts (comments, attachments) const counts = createElement('div', { className: 'task-counts' }); if (task.commentCount > 0) { counts.appendChild(createElement('span', {}, [ this.createIcon('message'), task.commentCount.toString() ])); } if (task.attachmentCount > 0) { counts.appendChild(createElement('span', {}, [ this.createIcon('paperclip'), task.attachmentCount.toString() ])); } footer.appendChild(counts); card.appendChild(footer); } return card; } renderTasks() { const columns = store.get('columns'); const searchResultIds = store.get('searchResultIds'); const filters = store.get('filters'); columns.forEach(column => { const columnBody = $(`.column-body[data-column-id="${column.id}"]`); if (!columnBody) return; const tasks = store.getTasksByColumn(column.id); const filteredTasks = filterTasks(tasks, filters, searchResultIds, columns); clearElement(columnBody); filteredTasks.forEach(task => { columnBody.appendChild(this.createTaskCard(task)); }); // Update count const countElement = $(`.column[data-column-id="${column.id}"] .column-count`); if (countElement) { countElement.textContent = filteredTasks.length.toString(); } }); // Check if dynamic layout adjustment is needed setTimeout(() => this.checkAndApplyDynamicLayout(), 100); } // ===================== // EVENT HANDLERS // ===================== handleBoardClick(e) { const target = e.target.closest('[data-action]'); if (!target) { // Check for task card click const taskCard = e.target.closest('.task-card'); if (taskCard) { const taskId = parseInt(taskCard.dataset.taskId); if (e.ctrlKey || e.metaKey) { store.selectTask(taskId, true); } else { this.openTaskModal(taskId); } return; } return; } const action = target.dataset.action; const columnId = parseInt(target.dataset.columnId); const taskId = parseInt(target.dataset.taskId); switch (action) { case 'add-task': this.openAddTaskModal(columnId); break; case 'edit-column': this.openEditColumnModal(columnId); break; case 'delete-column': this.confirmDeleteColumn(columnId); break; } } handleKeyboard(e) { // Only handle when board is active if (store.get('currentView') !== 'board') return; if (store.get('openModals').length > 0) return; // Escape to deselect if (e.key === 'Escape') { store.clearSelection(); return; } // Delete selected tasks if ((e.key === 'Delete' || e.key === 'Backspace') && !e.target.matches('input, textarea')) { const selected = store.get('selectedTaskIds'); if (selected.length > 0) { e.preventDefault(); this.confirmDeleteTasks(selected); } } // Ctrl+A to select all in focused column if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !e.target.matches('input, textarea')) { e.preventDefault(); // Select all visible tasks const allTaskIds = store.get('tasks') .filter(t => !t.archived) .map(t => t.id); store.setState({ selectedTaskIds: allTaskIds }); } } // ===================== // DRAG AND DROP // ===================== handleDragStart(e) { const taskCard = e.target.closest('.task-card'); const header = e.target.closest('.column-header'); const column = e.target.closest('.column'); if (taskCard) { this.draggedTask = taskCard; taskCard.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', taskCard.dataset.taskId); store.setDragState({ type: 'task', taskId: parseInt(taskCard.dataset.taskId) }); } else if (header && column) { // Header wird gezogen -> ganze Spalte verschieben this.draggedColumn = column; column.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', column.dataset.columnId); store.setDragState({ type: 'column', columnId: parseInt(column.dataset.columnId) }); } } handleDragEnd(e) { if (this.draggedTask) { this.draggedTask.classList.remove('dragging'); this.draggedTask = null; } if (this.draggedColumn) { this.draggedColumn.classList.remove('dragging'); this.draggedColumn = null; } // Remove all drop indicators $$('.column-body.drag-over').forEach(el => el.classList.remove('drag-over')); $$('.drop-indicator').forEach(el => el.remove()); // Remove column drag indicators $$('.column.drag-over-left, .column.drag-over-right').forEach(el => { el.classList.remove('drag-over-left', 'drag-over-right'); }); store.setDragState(null); } handleDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const dragState = store.get('dragState'); if (!dragState) return; if (dragState.type === 'task') { const columnBody = e.target.closest('.column-body'); if (columnBody) { columnBody.classList.add('drag-over'); // Show drop indicator const taskCard = e.target.closest('.task-card'); this.updateDropIndicator(columnBody, taskCard, e.clientY); } } else if (dragState.type === 'column') { const column = e.target.closest('.column'); // Entferne alle vorherigen Drag-Over-Klassen $$('.column.drag-over-left, .column.drag-over-right').forEach(col => { if (col !== column) { col.classList.remove('drag-over-left', 'drag-over-right'); } }); if (column && column !== this.draggedColumn) { const rect = column.getBoundingClientRect(); const midpoint = rect.left + rect.width / 2; if (e.clientX < midpoint) { column.classList.add('drag-over-left'); column.classList.remove('drag-over-right'); } else { column.classList.add('drag-over-right'); column.classList.remove('drag-over-left'); } } } } handleDragLeave(e) { const dragState = store.get('dragState'); if (dragState?.type === 'task') { const columnBody = e.target.closest('.column-body'); if (columnBody && !columnBody.contains(e.relatedTarget)) { columnBody.classList.remove('drag-over'); $$('.drop-indicator', columnBody).forEach(el => el.remove()); } } else if (dragState?.type === 'column') { const column = e.target.closest('.column'); // Nur entfernen wenn wirklich die Spalte verlassen wird if (column && !column.contains(e.relatedTarget)) { column.classList.remove('drag-over-left', 'drag-over-right'); } } } handleDrop(e) { e.preventDefault(); const dragState = store.get('dragState'); if (!dragState) return; // Clean up all drag-over states and indicators $$('.column-body.drag-over').forEach(el => el.classList.remove('drag-over')); $$('.drop-indicator').forEach(el => el.remove()); if (dragState.type === 'task') { const columnBody = e.target.closest('.column-body'); if (!columnBody) return; const columnId = parseInt(columnBody.dataset.columnId); const taskId = dragState.taskId; // Calculate position based on where the indicator was const taskCards = Array.from($$('.task-card', columnBody)).filter(card => parseInt(card.dataset.taskId) !== taskId ); // Find position based on mouse position let position = taskCards.length; // Default: end const mouseY = e.clientY; for (let i = 0; i < taskCards.length; i++) { const rect = taskCards[i].getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; if (mouseY < midpoint) { position = i; break; } } this.moveTask(taskId, columnId, position); } else if (dragState.type === 'column') { const targetColumn = e.target.closest('.column'); if (!targetColumn || targetColumn === this.draggedColumn) { // Cleanup $$('.column.drag-over-left, .column.drag-over-right').forEach(el => { el.classList.remove('drag-over-left', 'drag-over-right'); }); return; } const columns = store.get('columns'); const fromIndex = columns.findIndex(c => c.id === dragState.columnId); let toIndex = columns.findIndex(c => c.id === parseInt(targetColumn.dataset.columnId)); // Berechne Position basierend auf Maus-Position (links oder rechts der Ziel-Spalte) const rect = targetColumn.getBoundingClientRect(); const midpoint = rect.left + rect.width / 2; const dropOnRight = e.clientX > midpoint; // Wenn rechts gedroppt und von links kommend, nach rechts verschieben if (dropOnRight && fromIndex < toIndex) { // toIndex bleibt gleich (Position nach der Ziel-Spalte) } else if (dropOnRight && fromIndex > toIndex) { // Von rechts kommend, rechts droppend -> nach der Ziel-Spalte toIndex = toIndex + 1; } else if (!dropOnRight && fromIndex > toIndex) { // toIndex bleibt gleich (Position vor der Ziel-Spalte) } else if (!dropOnRight && fromIndex < toIndex) { // Von links kommend, links droppend -> vor der Ziel-Spalte toIndex = toIndex - 1; } if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { this.reorderColumns(fromIndex, toIndex); } // Cleanup $$('.column.drag-over-left, .column.drag-over-right').forEach(el => { el.classList.remove('drag-over-left', 'drag-over-right'); }); } } updateDropIndicator(columnBody, taskCard, mouseY) { // Remove existing indicators $$('.drop-indicator', columnBody).forEach(el => el.remove()); const indicator = createElement('div', { className: 'drop-indicator' }); if (!taskCard) { // Drop at the end columnBody.appendChild(indicator); } else { const rect = taskCard.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; if (mouseY < midpoint) { columnBody.insertBefore(indicator, taskCard); } else { columnBody.insertBefore(indicator, taskCard.nextSibling); } } } // ===================== // TASK OPERATIONS // ===================== async moveTask(taskId, columnId, position) { const projectId = store.get('currentProjectId'); // Optimistic update store.moveTask(taskId, columnId, position); try { await api.moveTask(projectId, taskId, columnId, position); syncManager.notifyTaskMoved(taskId, columnId, position); } catch (error) { console.error('Failed to move task:', error); if (!navigator.onLine) { await offlineManager.queueOperation({ type: 'task:move', projectId, taskId, columnId, position }); } else { this.showError('Aufgabe konnte nicht verschoben werden'); // Reload to restore correct state await this.loadData(); } } } async createTask(columnId, data) { const projectId = store.get('currentProjectId'); const tempId = generateTempId(); // Optimistic update const optimisticTask = { id: tempId, ...data, columnId: columnId, projectId: projectId, position: store.getTasksByColumn(columnId).length, createdAt: new Date().toISOString() }; store.addTask(optimisticTask); try { const task = await api.createTask(projectId, { ...data, columnId: columnId }); // Replace optimistic task with real one store.removeTask(tempId); store.addTask(task); syncManager.notifyTaskCreated(task); return task; } catch (error) { console.error('Failed to create task:', error); if (!navigator.onLine) { await offlineManager.queueOperation({ type: 'task:create', projectId, tempId, data: { ...data, columnId: columnId } }); return optimisticTask; } else { store.removeTask(tempId); throw error; } } } async deleteTask(taskId) { const projectId = store.get('currentProjectId'); const task = store.getTaskById(taskId); // Store for undo store.pushUndo({ type: 'DELETE_TASK', task: { ...task } }); // Optimistic delete store.removeTask(taskId); try { await api.deleteTask(projectId, taskId); syncManager.notifyTaskDeleted(taskId, task.title); } catch (error) { console.error('Failed to delete task:', error); if (!navigator.onLine) { await offlineManager.queueOperation({ type: 'task:delete', projectId, taskId }); } else { // Restore task on error store.addTask(task); throw error; } } } async confirmDeleteTasks(taskIds) { const count = taskIds.length; const message = count === 1 ? 'Möchten Sie diese Aufgabe wirklich löschen?' : `Möchten Sie diese ${count} Aufgaben wirklich löschen?`; window.dispatchEvent(new CustomEvent('confirm:show', { detail: { message, confirmText: 'Löschen', confirmClass: 'btn-danger', onConfirm: async () => { for (const taskId of taskIds) { await this.deleteTask(taskId); } store.clearSelection(); this.showSuccess(`${count} Aufgabe(n) gelöscht`); } } })); } // ===================== // COLUMN OPERATIONS // ===================== async createColumn(name, color = null, filterCategory = 'in_progress') { const projectId = store.get('currentProjectId'); const tempId = generateTempId(); const optimisticColumn = { id: tempId, name, color, filterCategory, projectId: projectId, position: store.get('columns').length }; store.addColumn(optimisticColumn); try { const column = await api.createColumn(projectId, { name, color, filterCategory }); store.removeColumn(tempId); store.addColumn(column); syncManager.notifyColumnCreated(column); return column; } catch (error) { console.error('Failed to create column:', error); if (!navigator.onLine) { await offlineManager.queueOperation({ type: 'column:create', projectId, tempId, data: { name } }); return optimisticColumn; } else { store.removeColumn(tempId); throw error; } } } async updateColumn(columnId, data) { const projectId = store.get('currentProjectId'); const originalColumn = store.get('columns').find(c => c.id === columnId); store.updateColumn(columnId, data); try { const column = await api.updateColumn(projectId, columnId, data); store.updateColumn(columnId, column); syncManager.notifyColumnUpdated(column); } catch (error) { console.error('Failed to update column:', error); if (!navigator.onLine) { await offlineManager.queueOperation({ type: 'column:update', projectId, columnId, data }); } else { store.updateColumn(columnId, originalColumn); throw error; } } } async deleteColumn(columnId) { const projectId = store.get('currentProjectId'); const column = store.get('columns').find(c => c.id === columnId); const tasksInColumn = store.getTasksByColumn(columnId); if (tasksInColumn.length > 0) { this.showError('Spalte enthält noch Aufgaben. Bitte verschieben oder löschen Sie diese zuerst.'); return; } store.removeColumn(columnId); try { await api.deleteColumn(projectId, columnId); syncManager.notifyColumnDeleted(columnId); } catch (error) { console.error('Failed to delete column:', error); if (!navigator.onLine) { await offlineManager.queueOperation({ type: 'column:delete', projectId, columnId }); } else { store.addColumn(column); throw error; } } } async reorderColumns(fromIndex, toIndex) { const projectId = store.get('currentProjectId'); const columns = [...store.get('columns')]; // Reorder const [moved] = columns.splice(fromIndex, 1); columns.splice(toIndex, 0, moved); const columnIds = columns.map(c => c.id); store.reorderColumns(columnIds); try { // API erwartet: columnId und newPosition await api.reorderColumns(projectId, moved.id, toIndex); syncManager.notifyColumnsReordered(columnIds); } catch (error) { console.error('Failed to reorder columns:', error); // Reload to fix order await this.loadData(); } } confirmDeleteColumn(columnId) { const column = store.get('columns').find(c => c.id === columnId); const tasksCount = store.getTasksByColumn(columnId).length; if (tasksCount > 0) { this.showError(`Die Spalte "${column.name}" enthält ${tasksCount} Aufgabe(n). Bitte verschieben oder löschen Sie diese zuerst.`); return; } window.dispatchEvent(new CustomEvent('confirm:show', { detail: { message: `Möchten Sie die Spalte "${column.name}" wirklich löschen?`, confirmText: 'Löschen', confirmClass: 'btn-danger', onConfirm: () => this.deleteColumn(columnId) } })); } // ===================== // DATA LOADING // ===================== async loadData() { const projectId = store.get('currentProjectId'); if (!projectId) return; store.setLoading(true); try { const [columns, tasks, labels] = await Promise.all([ api.getColumns(projectId), api.getTasks(projectId), api.getLabels(projectId) ]); store.setColumns(columns); store.setTasks(tasks); store.setLabels(labels); this.render(); } catch (error) { console.error('Failed to load board data:', error); // Try loading from cache if (!navigator.onLine || error.isOffline) { await offlineManager.loadOfflineData(); this.render(); } else { this.showError('Fehler beim Laden der Daten'); } } finally { store.setLoading(false); } } // ===================== // MODALS // ===================== openAddColumnModal() { window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'column-modal', mode: 'create' } })); } openEditColumnModal(columnId) { const column = store.get('columns').find(c => c.id === columnId); window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'column-modal', mode: 'edit', data: column } })); } openAddTaskModal(columnId) { window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'task-modal', mode: 'create', data: { columnId } } })); } openTaskModal(taskId) { const task = store.getTaskById(taskId); if (!task) return; store.setEditingTask(task); window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'task-modal', mode: 'edit', data: { taskId } } })); } // ===================== // HELPERS // ===================== createIcon(name) { const icons = { edit: '', trash: '', calendar: '', clock: '', message: '', paperclip: '' }; const span = createElement('span', { className: 'icon' }); span.innerHTML = icons[name] || ''; return span; } getPriorityLabel(priority) { const labels = { high: 'Hohe Priorität', medium: 'Mittlere Priorität', low: 'Niedrige Priorität' }; return labels[priority] || priority; } // Check if task is in the last column (completed) isTaskCompleted(task) { const columns = store.get('columns'); if (!columns || columns.length === 0) return false; const lastColumnId = columns[columns.length - 1].id; return task.columnId === lastColumnId; } formatEstimate(hours, minutes) { const parts = []; if (hours) parts.push(`${hours}h`); if (minutes) parts.push(`${minutes}m`); return parts.join(' ') || '-'; } showError(message) { window.dispatchEvent(new CustomEvent('toast:show', { detail: { message, type: 'error' } })); } showSuccess(message) { window.dispatchEvent(new CustomEvent('toast:show', { detail: { message, type: 'success' } })); } // ===================== // WEEK STRIP CALENDAR // ===================== getMonday(date) { const d = new Date(date); const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1); d.setDate(diff); d.setHours(0, 0, 0, 0); return d; } // Format date as YYYY-MM-DD in local timezone (avoids UTC conversion issues) formatLocalDate(date) { 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}`; } bindWeekStripEvents() { $('#week-strip-prev')?.addEventListener('click', () => { this.weekStripDate.setDate(this.weekStripDate.getDate() - 7); this.renderWeekStrip(); }); $('#week-strip-next')?.addEventListener('click', () => { this.weekStripDate.setDate(this.weekStripDate.getDate() + 7); this.renderWeekStrip(); }); $('#week-strip-today')?.addEventListener('click', () => { this.weekStripDate = this.getMonday(new Date()); this.renderWeekStrip(); }); // Tooltip cleanup on mouse leave document.addEventListener('mouseleave', () => this.hideTooltip()); // Listen for app refresh events (task create/update/delete) window.addEventListener('app:refresh', () => this.renderWeekStrip()); // Listen for task modal close (in case task was edited) window.addEventListener('modal:close', (e) => { if (e.detail?.modalId === 'task-modal') { // Small delay to ensure store is updated setTimeout(() => this.renderWeekStrip(), 100); } }); } renderWeekStrip() { const container = $('#week-strip-days'); if (!container) return; const columns = store.get('columns'); const lastColumnId = columns.length > 0 ? columns[columns.length - 1].id : null; // Only show open and in-progress tasks (not completed/last column) const tasks = store.get('tasks').filter(t => !t.archived && t.columnId !== lastColumnId ); const today = new Date(); today.setHours(0, 0, 0, 0); const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; let html = ''; for (let i = 0; i < 7; i++) { const date = new Date(this.weekStripDate); date.setDate(date.getDate() + i); date.setHours(0, 0, 0, 0); const dateStr = this.formatLocalDate(date); const isToday = this.formatLocalDate(date) === this.formatLocalDate(today); const isWeekend = i >= 5; // Find tasks that start or end on this day const dayTasks = this.getTasksForDay(tasks, dateStr); const classes = ['week-strip-day']; if (isToday) classes.push('today'); if (isWeekend) classes.push('weekend'); html += `
${dayNames[i]} ${date.getDate()}
${this.renderDayDots(dayTasks, dateStr)}
`; } container.innerHTML = html; // Bind dot events container.querySelectorAll('.week-strip-dot').forEach(dot => { dot.addEventListener('mouseenter', (e) => this.showTaskTooltip(e, dot)); dot.addEventListener('mouseleave', () => this.hideTooltip()); dot.addEventListener('click', (e) => this.openTaskFromDot(e, dot)); }); } getTasksForDay(tasks, dateStr) { // Gruppiere Aufgaben nach Spalte const columnGroups = new Map(); const columns = store.get('columns'); tasks.forEach(task => { const startDate = task.startDate ? task.startDate.split('T')[0] : null; const dueDate = task.dueDate ? task.dueDate.split('T')[0] : null; const isStart = startDate === dateStr; const isEnd = dueDate === dateStr; if (isStart || isEnd) { const columnId = task.columnId; if (!columnGroups.has(columnId)) { const column = columns.find(c => c.id === columnId); columnGroups.set(columnId, { column, tasks: [] }); } columnGroups.get(columnId).tasks.push({ task, isStart, isEnd }); } }); return columnGroups; } renderDayDots(columnGroups, dateStr) { if (columnGroups.size === 0) return ''; const dots = []; columnGroups.forEach(({ column, tasks }, columnId) => { const color = column?.color || '#6B7280'; const taskIds = tasks.map(t => t.task.id).join(','); // Bestimme Dot-Typ basierend auf allen Aufgaben der Spalte const hasStart = tasks.some(t => t.isStart); const hasEnd = tasks.some(t => t.isEnd); let typeClass = ''; if (hasStart && hasEnd) { typeClass = 'both'; } else if (hasStart) { typeClass = 'start'; } else { typeClass = 'end'; } dots.push(``); }); return dots.join(''); } showTaskTooltip(event, dot) { const taskIds = dot.dataset.taskIds ? dot.dataset.taskIds.split(',').map(id => parseInt(id)) : []; const columnName = dot.dataset.columnName || 'Unbekannt'; const columnId = parseInt(dot.dataset.columnId); if (taskIds.length === 0) return; const allTasks = store.get('tasks'); const columns = store.get('columns'); const column = columns.find(c => c.id === columnId); const color = column?.color || '#6B7280'; // Sammle alle Aufgaben mit ihren Start/Ende-Infos const tasksWithDates = taskIds.map(taskId => { const task = allTasks.find(t => t.id === taskId); if (!task) return null; const dayDate = dot.closest('.week-strip-day')?.dataset.date; const startDate = task.startDate ? task.startDate.split('T')[0] : null; const dueDate = task.dueDate ? task.dueDate.split('T')[0] : null; const isStart = startDate === dayDate; const isEnd = dueDate === dayDate; let typeLabel = ''; if (isStart && isEnd) { typeLabel = 'Start & Ende'; } else if (isStart) { typeLabel = 'Start'; } else if (isEnd) { typeLabel = 'Ende'; } return { task, typeLabel, isStart, isEnd }; }).filter(Boolean); if (tasksWithDates.length === 0) return; // Create tooltip this.hideTooltip(); const tooltip = document.createElement('div'); tooltip.className = 'week-strip-tooltip'; const taskCount = tasksWithDates.length; const taskListHtml = tasksWithDates.map(({ task, typeLabel, isStart }) => `
${this.escapeHtml(task.title)} (${typeLabel})
`).join(''); tooltip.innerHTML = `
${this.escapeHtml(columnName)} ${taskCount} Aufgabe${taskCount > 1 ? 'n' : ''}
${taskListHtml}
`; document.body.appendChild(tooltip); this.tooltip = tooltip; // Position tooltip const rect = dot.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); let top = rect.bottom + 8; // Keep within viewport if (left < 10) left = 10; if (left + tooltipRect.width > window.innerWidth - 10) { left = window.innerWidth - tooltipRect.width - 10; } if (top + tooltipRect.height > window.innerHeight - 10) { top = rect.top - tooltipRect.height - 8; } tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } hideTooltip() { if (this.tooltip) { this.tooltip.remove(); this.tooltip = null; } } openTaskFromDot(event, dot) { event.stopPropagation(); const taskIds = dot.dataset.taskIds ? dot.dataset.taskIds.split(',').map(id => parseInt(id)) : []; if (taskIds.length === 0) return; // Öffne die erste Aufgabe const taskId = taskIds[0]; window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'task-modal', mode: 'edit', data: { taskId } } })); } escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ===================== // LAYOUT PREFERENCES // ===================== loadLayoutPreference() { const stored = localStorage.getItem('taskmate:boardLayout'); return stored === 'multiColumn'; } saveLayoutPreference(multiColumn) { localStorage.setItem('taskmate:boardLayout', multiColumn ? 'multiColumn' : 'single'); } toggleLayout() { this.multiColumnLayout = !this.multiColumnLayout; this.saveLayoutPreference(this.multiColumnLayout); this.applyLayoutClass(); this.checkAndApplyDynamicLayout(); this.showSuccess(this.multiColumnLayout ? 'Mehrspalten-Layout aktiviert' : 'Einspalten-Layout aktiviert' ); } applyLayoutClass() { const toggleBtn = $('#btn-toggle-layout'); if (this.multiColumnLayout) { this.boardElement?.classList.add('multi-column-layout'); toggleBtn?.classList.add('active'); } else { this.boardElement?.classList.remove('multi-column-layout'); toggleBtn?.classList.remove('active'); // Remove all dynamic classes when disabled const columns = this.boardElement?.querySelectorAll('.column'); columns?.forEach(column => { const columnBody = column.querySelector('.column-body'); columnBody?.classList.remove('dynamic-2-columns', 'dynamic-3-columns'); column.classList.remove('expanded-2x', 'expanded-3x'); }); } } checkAndApplyDynamicLayout() { if (!this.multiColumnLayout || !this.boardElement) return; // Debug logging console.log('[Layout Check] Checking dynamic layout...'); // Check each column to see if scrolling is needed const columns = this.boardElement.querySelectorAll('.column'); columns.forEach(column => { const columnBody = column.querySelector('.column-body'); if (!columnBody) return; // Remove dynamic classes first columnBody.classList.remove('dynamic-2-columns', 'dynamic-3-columns'); column.classList.remove('expanded-2x', 'expanded-3x'); // Force reflow to get accurate measurements columnBody.offsetHeight; const scrollHeight = columnBody.scrollHeight; const clientHeight = columnBody.clientHeight; const hasOverflow = scrollHeight > clientHeight; console.log('[Layout Check]', { column: column.dataset.columnId, scrollHeight, clientHeight, hasOverflow, ratio: scrollHeight / clientHeight }); // Check if content overflows if (hasOverflow && clientHeight > 0) { // Calculate how many columns we need based on content height const ratio = scrollHeight / clientHeight; if (ratio > 2.5 && window.innerWidth >= 1800) { // Need 3 columns console.log('[Layout] Applying 3 columns'); columnBody.classList.add('dynamic-3-columns'); column.classList.add('expanded-3x'); } else if (ratio > 1.1 && window.innerWidth >= 1400) { // Need 2 columns (reduced threshold to 1.1) console.log('[Layout] Applying 2 columns'); columnBody.classList.add('dynamic-2-columns'); column.classList.add('expanded-2x'); } } }); } } // Create and export singleton const boardManager = new BoardManager(); export default boardManager;