/** * 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; 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(); }); } // ===================== // 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)); } // ===================== // RENDERING // ===================== render() { if (!this.boardElement) return; const columns = store.get('columns'); clearElement(this.boardElement); 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); } 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 }, draggable: 'true' }); // Header with column color const header = createElement('div', { className: 'column-header' }); // 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'; const avatar = createElement('span', { className: 'avatar task-assignee-avatar stacked', style: { backgroundColor: color, zIndex: 10 - index }, title: name }, [getInitials(name)]); 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?.username || task.assignedName || 'Benutzer'; const assignee = createElement('div', { className: 'task-assignees' }, [ createElement('span', { className: 'avatar task-assignee-avatar', style: { backgroundColor: currentColor }, title: currentName }, [getInitials(currentName)]) ]); 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(); } }); } // ===================== // 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 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 (column && e.target.closest('.column-header')) { 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()); 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'); if (column && column !== this.draggedColumn) { const rect = column.getBoundingClientRect(); const midpoint = rect.left + rect.width / 2; if (e.clientX < midpoint) { column.style.borderLeft = '3px solid var(--accent)'; column.style.borderRight = ''; } else { column.style.borderRight = '3px solid var(--accent)'; column.style.borderLeft = ''; } } } } handleDragLeave(e) { 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()); } const column = e.target.closest('.column'); if (column) { column.style.borderLeft = ''; column.style.borderRight = ''; } } 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) return; const columns = store.get('columns'); const fromIndex = columns.findIndex(c => c.id === dragState.columnId); const toIndex = columns.findIndex(c => c.id === parseInt(targetColumn.dataset.columnId)); if (fromIndex !== -1 && toIndex !== -1) { this.reorderColumns(fromIndex, toIndex); } targetColumn.style.borderLeft = ''; targetColumn.style.borderRight = ''; } } 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 { await api.reorderColumns(projectId, columnIds); 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; } } // Create and export singleton const boardManager = new BoardManager(); export default boardManager;