/** * TASKMATE - Task Modal Module * ============================ * Task creation and editing modal */ import store from './store.js'; import api from './api.js'; import syncManager from './sync.js'; import { $, $$, createElement, clearElement, formatDate, formatDateTime, formatRelativeTime, formatFileSize, getInitials, hexToRgba, getContrastColor, isImageFile, debounce, generateTempId } from './utils.js'; class TaskModalManager { constructor() { this.modal = $('#task-modal'); this.form = $('#task-form'); this.mode = 'create'; this.taskId = null; this.columnId = null; this.originalTask = null; this.subtasks = []; this.links = []; this.files = []; this.comments = []; this.history = []; this.init(); } init() { this.bindEvents(); // Listen for modal events window.addEventListener('modal:open', (e) => { if (e.detail.modalId === 'task-modal') { this.open(e.detail.mode, e.detail.data); } }); window.addEventListener('modal:close', (e) => { if (e.detail.modalId === 'task-modal') { this.close(); } }); window.addEventListener('task:refresh', (e) => { if (this.taskId === e.detail.taskId) { this.loadTaskData(); } }); } bindEvents() { // Form submission this.form?.addEventListener('submit', (e) => this.handleSubmit(e)); // Close buttons $$('.modal-close, [data-dismiss="modal"]', this.modal)?.forEach(btn => { btn.addEventListener('click', () => this.close()); }); // Delete button $('#btn-delete-task')?.addEventListener('click', () => this.handleDelete()); // Duplicate button $('#btn-duplicate-task')?.addEventListener('click', () => this.handleDuplicate()); // Archive button $('#btn-archive-task')?.addEventListener('click', () => this.handleArchive()); // Restore button $('#btn-restore-task')?.addEventListener('click', () => this.handleRestore()); // Tab navigation $$('.tab-btn', this.modal)?.forEach(btn => { btn.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab)); }); // Subtask handling $('#btn-add-subtask')?.addEventListener('click', () => this.addSubtask()); $('#subtask-input')?.addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.addSubtask(); } }); // Link handling $('#btn-add-link')?.addEventListener('click', () => this.addLink()); $('#link-url')?.addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.addLink(); } }); // File upload const fileInput = $('#file-input'); const fileDropArea = $('.file-upload-area', this.modal); fileInput?.addEventListener('change', (e) => this.handleFileSelect(e.target.files)); fileDropArea?.addEventListener('dragover', (e) => { e.preventDefault(); fileDropArea.classList.add('drag-over'); }); fileDropArea?.addEventListener('dragleave', () => { fileDropArea.classList.remove('drag-over'); }); fileDropArea?.addEventListener('drop', (e) => { e.preventDefault(); fileDropArea.classList.remove('drag-over'); this.handleFileSelect(e.dataTransfer.files); }); // Comment submission $('#btn-add-comment')?.addEventListener('click', () => this.addComment()); $('#comment-input')?.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.addComment(); } }); // Label selection - auto-save $$('.label-checkbox input', this.modal)?.forEach(input => { input.addEventListener('change', () => { this.updateLabels(); if (this.mode === 'edit' && this.taskId) { this.autoSaveTask(); } }); }); // Create new label $('#btn-add-label')?.addEventListener('click', () => this.openLabelModal()); // Description auto-save const descriptionInput = $('#task-description'); descriptionInput?.addEventListener('input', debounce(() => { if (this.mode === 'edit' && this.taskId) { this.autoSaveTask(); } }, 1000)); // Auto-save for all form fields (assignees handled separately via multi-select) const autoSaveFields = ['task-priority', 'task-status', 'task-start', 'task-due', 'task-time-hours', 'task-time-mins']; autoSaveFields.forEach(fieldId => { const field = $(`#${fieldId}`); if (field) { field.addEventListener('change', () => { if (this.mode === 'edit' && this.taskId) { this.autoSaveTask(); } }); } }); // Initialize assignees multi-select dropdown this.initAssigneesDropdown(); // Debounced auto-save for title (on input, not just blur) const titleInput = $('#task-title'); if (titleInput) { titleInput.addEventListener('input', debounce(() => { if (this.mode === 'edit' && this.taskId) { this.autoSaveTask(); } }, 1000)); } // Zurück button - save and close (edit mode only) $('#btn-back-task')?.addEventListener('click', async () => { if (this.mode === 'edit' && this.taskId) { await this.autoSaveTask(); } this.close(); }); // Save button - create new task (create mode only) $('#btn-save-task')?.addEventListener('click', async () => { if (this.mode === 'create') { await this.createTask(); this.close(); } }); // Cancel button - close without saving (create mode only) $('#btn-cancel-task')?.addEventListener('click', () => { this.close(); }); } // ===================== // MODAL CONTROL // ===================== async open(mode, data = {}) { this.mode = mode; this.columnId = data.columnId || null; this.taskId = data.taskId || null; // Reset form this.form?.reset(); this.subtasks = []; this.links = []; this.files = []; this.comments = []; this.history = []; // Update modal title const title = this.modal?.querySelector('.modal-header h2'); if (title) { title.textContent = mode === 'create' ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'; } // Show/hide action buttons based on mode const deleteBtn = $('#btn-delete-task'); const duplicateBtn = $('#btn-duplicate-task'); const archiveBtn = $('#btn-archive-task'); const restoreBtn = $('#btn-restore-task'); const saveBtn = $('#btn-save-task'); const cancelBtn = $('#btn-cancel-task'); const backBtn = $('#btn-back-task'); // Left side buttons (only in edit mode, archive/restore toggled in loadTaskData) if (deleteBtn) deleteBtn.classList.toggle('hidden', mode === 'create'); if (duplicateBtn) duplicateBtn.classList.toggle('hidden', mode === 'create'); if (archiveBtn) archiveBtn.classList.toggle('hidden', mode === 'create'); if (restoreBtn) restoreBtn.classList.add('hidden'); // Always hide initially, shown in loadTaskData if archived // Right side buttons (create vs edit mode) if (saveBtn) saveBtn.classList.toggle('hidden', mode !== 'create'); if (cancelBtn) cancelBtn.classList.toggle('hidden', mode !== 'create'); if (backBtn) backBtn.classList.toggle('hidden', mode === 'create'); // Populate labels this.renderLabels(); // Populate assignees this.renderAssignees(); // Populate columns this.renderColumns(); if (mode === 'edit' && this.taskId) { await this.loadTaskData(); } else { // Set default column if (this.columnId) { const columnSelect = $('#task-status'); if (columnSelect) columnSelect.value = this.columnId; } // Set default assignee to current user const currentUser = store.get('currentUser'); if (currentUser) { this.setSelectedAssignees([currentUser.id]); } else { this.setSelectedAssignees([]); } } // Switch to details tab this.switchTab('details'); // Show modal this.showModal(); } async close() { // Auto-save before closing (for edit mode) if (this.mode === 'edit' && this.taskId) { await this.autoSaveTask(); } this.hideModal(); this.taskId = null; this.columnId = null; this.originalTask = null; store.setEditingTask(null); store.closeModal('task-modal'); } showModal() { const overlay = $('.modal-overlay'); if (overlay) { overlay.classList.remove('hidden'); overlay.classList.add('visible'); } if (this.modal) { this.modal.classList.remove('hidden'); this.modal.classList.add('visible'); } store.openModal('task-modal'); // Focus title input setTimeout(() => { $('#task-title')?.focus(); }, 100); } hideModal() { const overlay = $('.modal-overlay'); if (overlay) { overlay.classList.remove('visible'); setTimeout(() => overlay.classList.add('hidden'), 200); } if (this.modal) { this.modal.classList.remove('visible'); setTimeout(() => this.modal.classList.add('hidden'), 200); } } // ===================== // DATA LOADING // ===================== async loadTaskData() { const projectId = store.get('currentProjectId'); try { const task = await api.getTask(projectId, this.taskId); this.originalTask = { ...task }; // Populate form fields this.populateForm(task); // Update archive/restore buttons based on task status const archiveBtn = $('#btn-archive-task'); const restoreBtn = $('#btn-restore-task'); if (task.archived) { if (archiveBtn) archiveBtn.classList.add('hidden'); if (restoreBtn) restoreBtn.classList.remove('hidden'); } else { if (archiveBtn) archiveBtn.classList.remove('hidden'); if (restoreBtn) restoreBtn.classList.add('hidden'); } // Load related data await Promise.all([ this.loadSubtasks(), this.loadLinks(), this.loadFiles(), this.loadComments(), this.loadHistory() ]); } catch (error) { console.error('Failed to load task:', error); this.showError('Fehler beim Laden der Aufgabe'); } } populateForm(task) { // Convert total minutes back to hours and minutes for display const totalMinutes = task.timeEstimateMin || 0; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; // Basic fields - use backend field names (camelCase) const fields = { 'task-title': task.title, 'task-description': task.description || '', 'task-priority': task.priority || 'medium', 'task-status': task.columnId, 'task-start': task.startDate ? task.startDate.split('T')[0] : '', 'task-due': task.dueDate ? task.dueDate.split('T')[0] : '', 'task-time-hours': hours || '', 'task-time-mins': minutes || '' }; Object.entries(fields).forEach(([id, value]) => { const input = $(`#${id}`); if (input) input.value = value; }); // Assignees (Mehrfachzuweisung) if (task.assignees && Array.isArray(task.assignees)) { const assigneeIds = task.assignees.map(a => a.id); this.setSelectedAssignees(assigneeIds); } else if (task.assignedTo) { // Rückwärtskompatibilität this.setSelectedAssignees([task.assignedTo]); } else { this.setSelectedAssignees([]); } // Labels if (task.labels) { task.labels.forEach(label => { const checkbox = $(`#label-${label.id}`); if (checkbox) checkbox.checked = true; }); } } async loadSubtasks() { try { const projectId = store.get('currentProjectId'); this.subtasks = await api.getSubtasks(projectId, this.taskId); this.renderSubtasks(); } catch (error) { console.error('Failed to load subtasks:', error); } } async loadLinks() { try { const projectId = store.get('currentProjectId'); this.links = await api.getLinks(projectId, this.taskId); this.renderLinks(); } catch (error) { console.error('Failed to load links:', error); } } async loadFiles() { try { const projectId = store.get('currentProjectId'); this.files = await api.getFiles(projectId, this.taskId); this.renderFiles(); } catch (error) { console.error('Failed to load files:', error); } } async loadComments() { try { const projectId = store.get('currentProjectId'); this.comments = await api.getComments(projectId, this.taskId); this.renderComments(); } catch (error) { console.error('Failed to load comments:', error); } } async loadHistory() { try { const projectId = store.get('currentProjectId'); this.history = await api.getTaskHistory(projectId, this.taskId); this.renderHistory(); } catch (error) { console.error('Failed to load history:', error); } } // ===================== // FORM SUBMISSION // ===================== async handleSubmit(e) { e.preventDefault(); // Form submit is handled by auto-save and Zurück button // This just prevents default form submission } getFormData() { // Convert hours and minutes to total minutes for backend const hours = parseInt($('#task-time-hours')?.value) || 0; const minutes = parseInt($('#task-time-mins')?.value) || 0; const timeEstimateMin = (hours * 60 + minutes) || null; return { title: $('#task-title')?.value.trim() || '', description: $('#task-description')?.value.trim() || '', priority: $('#task-priority')?.value || 'medium', columnId: parseInt($('#task-status')?.value) || this.columnId, assignees: this.getSelectedAssignees(), startDate: $('#task-start')?.value || null, dueDate: $('#task-due')?.value || null, timeEstimateMin: timeEstimateMin, labels: this.getSelectedLabels() }; } getSelectedLabels() { const checkboxes = $$('.label-checkbox input:checked', this.modal); return checkboxes.map(cb => parseInt(cb.value)); } async updateTask(data) { const projectId = store.get('currentProjectId'); const changes = this.getChanges(data); const task = await api.updateTask(projectId, this.taskId, data); store.updateTask(this.taskId, task); syncManager.notifyTaskUpdated(task, changes); this.showSuccess('Aufgabe aktualisiert'); } getChanges(newData) { const changes = []; const original = this.originalTask; if (!original) return ['created']; if (newData.title !== original.title) changes.push('title'); if (newData.description !== original.description) changes.push('description'); if (newData.priority !== original.priority) changes.push('priority'); if (newData.columnId !== original.columnId) changes.push('status'); // Assignees vergleichen const newAssignees = (newData.assignees || []).sort().join(','); const originalAssignees = (original.assignees || []).map(a => a.id).sort().join(','); if (newAssignees !== originalAssignees) changes.push('assignees'); if (newData.startDate !== original.startDate) changes.push('startDate'); if (newData.dueDate !== original.dueDate) changes.push('dueDate'); return changes; } // ===================== // TASK ACTIONS // ===================== async handleDelete() { window.dispatchEvent(new CustomEvent('confirm:show', { detail: { message: 'Möchten Sie diese Aufgabe wirklich löschen?', confirmText: 'Löschen', confirmClass: 'btn-danger', onConfirm: async () => { try { const projectId = store.get('currentProjectId'); await api.deleteTask(projectId, this.taskId); store.removeTask(this.taskId); syncManager.notifyTaskDeleted(this.taskId, this.originalTask?.title); this.close(); this.showSuccess('Aufgabe gelöscht'); } catch (error) { this.showError('Fehler beim Löschen'); } } } })); } async handleDuplicate() { try { const projectId = store.get('currentProjectId'); const task = await api.duplicateTask(projectId, this.taskId); store.addTask(task); syncManager.notifyTaskCreated(task); this.close(); this.showSuccess('Aufgabe dupliziert'); } catch (error) { this.showError('Fehler beim Duplizieren'); } } async handleArchive() { try { const projectId = store.get('currentProjectId'); await api.archiveTask(projectId, this.taskId); store.updateTask(this.taskId, { archived: true }); this.close(); this.showSuccess('Aufgabe archiviert'); } catch (error) { this.showError('Fehler beim Archivieren'); } } async handleRestore() { try { const projectId = store.get('currentProjectId'); await api.restoreTask(projectId, this.taskId); store.updateTask(this.taskId, { archived: false }); this.close(); this.showSuccess('Aufgabe wiederhergestellt'); } catch (error) { this.showError('Fehler beim Wiederherstellen'); } } async autoSaveDescription() { // Deprecated - use autoSaveTask instead await this.autoSaveTask(); } async autoSaveTask() { if (!this.taskId || this.mode !== 'edit') return; const formData = this.getFormData(); // Don't save if title is empty if (!formData.title.trim()) return; // Check if there are actual changes if (!this.hasChanges(formData)) return; this.showSaveStatus('saving'); try { const projectId = store.get('currentProjectId'); const task = await api.updateTask(projectId, this.taskId, formData); store.updateTask(this.taskId, task); // Update original task to track changes this.originalTask = { ...this.originalTask, ...formData }; this.showSaveStatus('saved'); } catch (error) { console.error('Auto-save failed:', error); this.showSaveStatus('error'); } } hasChanges(formData) { if (!this.originalTask) return true; return ( formData.title !== this.originalTask.title || formData.description !== (this.originalTask.description || '') || formData.priority !== this.originalTask.priority || formData.columnId !== this.originalTask.columnId || formData.assignedTo !== this.originalTask.assignedTo || formData.startDate !== this.originalTask.startDate || formData.dueDate !== this.originalTask.dueDate || formData.timeEstimateMin !== this.originalTask.timeEstimateMin ); } showSaveStatus(status) { const indicator = $('#save-status-indicator'); if (!indicator) return; indicator.className = 'save-status-indicator'; switch (status) { case 'saving': indicator.textContent = 'Speichert...'; indicator.classList.add('saving'); break; case 'saved': indicator.textContent = 'Gespeichert'; indicator.classList.add('saved'); // Hide after 2 seconds setTimeout(() => { indicator.classList.remove('saved'); indicator.textContent = ''; }, 2000); break; case 'error': indicator.textContent = 'Fehler beim Speichern'; indicator.classList.add('error'); break; } } async createTask(data = null) { const formData = data || this.getFormData(); // Validation if (!formData.title.trim()) { this.showError('Bitte einen Titel eingeben'); $('#task-title')?.focus(); return false; } try { const projectId = store.get('currentProjectId'); const task = await api.createTask(projectId, formData); store.addTask(task); syncManager.notifyTaskCreated(task); this.showSuccess('Aufgabe erstellt'); return true; } catch (error) { console.error('Failed to create task:', error); this.showError('Fehler beim Erstellen'); return false; } } // ===================== // TABS // ===================== switchTab(tabId) { // Update tab buttons $$('.tab-btn', this.modal)?.forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabId); }); // Update tab panels $$('.tab-panel', this.modal)?.forEach(panel => { panel.classList.toggle('active', panel.id === `tab-${tabId}`); }); } // ===================== // RENDERING // ===================== renderLabels() { const container = $('#task-labels'); if (!container) return; const labels = store.get('labels'); clearElement(container); labels.forEach(label => { const checkbox = createElement('label', { className: 'label-checkbox' }, [ createElement('input', { type: 'checkbox', id: `label-${label.id}`, value: label.id }), createElement('span', { className: 'checkmark' }), createElement('span', { className: 'label-color-dot', style: { backgroundColor: label.color } }), createElement('span', {}, [label.name]) ]); container.appendChild(checkbox); }); // Add "create label" button const addBtn = createElement('button', { type: 'button', className: 'btn-add-label', id: 'btn-add-label' }, ['+ Neues Label']); addBtn.addEventListener('click', () => this.openLabelModal()); container.appendChild(addBtn); } // Multi-Select Dropdown für Mitarbeiter initialisieren initAssigneesDropdown() { const dropdown = $('#assignees-dropdown'); const trigger = $('#assignees-trigger'); const options = $('#assignees-options'); if (!dropdown || !trigger || !options) return; // Position dropdown options below trigger (links ausgerichtet wie Startdatum) const positionDropdown = () => { const triggerRect = trigger.getBoundingClientRect(); // Startdatum-Feld finden und dessen linke Position verwenden const startDateInput = document.getElementById('task-start'); const leftPos = startDateInput ? startDateInput.getBoundingClientRect().left : triggerRect.left; options.style.top = `${triggerRect.bottom + 2}px`; options.style.left = `${leftPos}px`; options.style.width = 'auto'; options.style.minWidth = '200px'; }; // Toggle dropdown trigger.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = dropdown.classList.toggle('open'); options.classList.toggle('hidden'); if (isOpen) { positionDropdown(); } }); // Close when clicking outside document.addEventListener('click', (e) => { if (!dropdown.contains(e.target) && !options.contains(e.target)) { dropdown.classList.remove('open'); options.classList.add('hidden'); } }); // Close on scroll in modal body const modalBody = dropdown.closest('.modal-body'); if (modalBody) { modalBody.addEventListener('scroll', () => { if (dropdown.classList.contains('open')) { dropdown.classList.remove('open'); options.classList.add('hidden'); } }); } } renderAssignees() { const optionsContainer = $('#assignees-options'); if (!optionsContainer) return; const users = store.get('users'); optionsContainer.innerHTML = ''; users.forEach(user => { const option = createElement('div', { class: 'multi-select-option' }); const checkbox = createElement('input', { type: 'checkbox', value: user.id, id: `assignee-${user.id}` }); checkbox.addEventListener('change', () => { this.updateAssigneesDisplay(); if (this.mode === 'edit' && this.taskId) { this.autoSaveTask(); } }); const avatar = createElement('div', { class: 'multi-select-option-avatar', style: `background-color: ${user.color || '#6366F1'}` }, [getInitials(user.display_name || user.username)]); const name = createElement('span', { class: 'multi-select-option-name' }, [user.display_name || user.username]); option.appendChild(checkbox); option.appendChild(avatar); option.appendChild(name); // Click on option also toggles checkbox option.addEventListener('click', (e) => { if (e.target !== checkbox) { checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change')); } }); optionsContainer.appendChild(option); }); } getSelectedAssignees() { const checkboxes = $$('#assignees-options input[type="checkbox"]:checked'); return checkboxes.map(cb => parseInt(cb.value)); } setSelectedAssignees(assigneeIds) { // Alle Checkboxen zurücksetzen $$('#assignees-options input[type="checkbox"]').forEach(cb => { cb.checked = false; }); // Ausgewählte Mitarbeiter setzen if (assigneeIds && Array.isArray(assigneeIds)) { assigneeIds.forEach(id => { const checkbox = $(`#assignee-${id}`); if (checkbox) checkbox.checked = true; }); } this.updateAssigneesDisplay(); } updateAssigneesDisplay() { const trigger = $('#assignees-trigger'); if (!trigger) return; const selectedIds = this.getSelectedAssignees(); const users = store.get('users'); const selectedUsers = users.filter(u => selectedIds.includes(u.id)); if (selectedUsers.length === 0) { trigger.innerHTML = ` Mitarbeitende auswählen... `; } else { const tags = selectedUsers.map(user => ` ${getInitials(user.display_name || user.username)} ${user.display_name || user.username} `).join(''); trigger.innerHTML = `
${tags}
`; } } renderColumns() { const select = $('#task-status'); if (!select) return; const columns = store.get('columns'); select.innerHTML = ''; columns.forEach(column => { const option = createElement('option', { value: column.id }, [column.name]); select.appendChild(option); }); } renderSubtasks() { const container = $('#subtasks-container'); if (!container) return; clearElement(container); this.subtasks.forEach((subtask, index) => { const item = createElement('div', { className: `subtask-item ${subtask.completed ? 'completed' : ''}`, dataset: { subtaskId: subtask.id, position: index }, draggable: 'true' }, [ // Drag Handle createElement('span', { className: 'subtask-drag-handle', title: 'Ziehen zum Verschieben' }, ['⋮⋮']), // Checkbox createElement('input', { type: 'checkbox', checked: subtask.completed, onchange: () => this.toggleSubtask(subtask.id) }), // Titel (Doppelklick zum Bearbeiten) createElement('span', { className: 'subtask-title', ondblclick: (e) => this.startEditSubtask(subtask.id, e.target) }, [subtask.title]), // Aktionen createElement('div', { className: 'subtask-actions' }, [ createElement('button', { type: 'button', className: 'subtask-edit', title: 'Bearbeiten', onclick: (e) => this.startEditSubtask(subtask.id, e.target.closest('.subtask-item').querySelector('.subtask-title')) }, ['✎']), createElement('button', { type: 'button', className: 'subtask-delete', title: 'Löschen', onclick: () => this.deleteSubtask(subtask.id) }, ['×']) ]) ]); // Drag & Drop Events item.addEventListener('dragstart', (e) => this.handleSubtaskDragStart(e, subtask.id, index)); item.addEventListener('dragover', (e) => this.handleSubtaskDragOver(e)); item.addEventListener('dragleave', (e) => this.handleSubtaskDragLeave(e)); item.addEventListener('drop', (e) => this.handleSubtaskDrop(e, subtask.id, index)); item.addEventListener('dragend', (e) => this.handleSubtaskDragEnd(e)); container.appendChild(item); }); // Update progress display this.updateSubtaskProgress(); } // Subtask Drag & Drop handleSubtaskDragStart(e, subtaskId, position) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', JSON.stringify({ subtaskId, position })); e.target.classList.add('dragging'); this.draggedSubtaskId = subtaskId; } handleSubtaskDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const item = e.target.closest('.subtask-item'); if (item && !item.classList.contains('dragging')) { const rect = item.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; item.classList.remove('drag-over-top', 'drag-over-bottom'); if (e.clientY < midpoint) { item.classList.add('drag-over-top'); } else { item.classList.add('drag-over-bottom'); } } } handleSubtaskDragLeave(e) { const item = e.target.closest('.subtask-item'); if (item) { item.classList.remove('drag-over-top', 'drag-over-bottom'); } } handleSubtaskDragEnd(e) { e.target.classList.remove('dragging'); $$('.subtask-item').forEach(item => { item.classList.remove('drag-over-top', 'drag-over-bottom'); }); this.draggedSubtaskId = null; } async handleSubtaskDrop(e, targetSubtaskId, targetPosition) { e.preventDefault(); const item = e.target.closest('.subtask-item'); if (item) { item.classList.remove('drag-over-top', 'drag-over-bottom'); } if (!this.draggedSubtaskId || this.draggedSubtaskId === targetSubtaskId) return; const draggedIndex = this.subtasks.findIndex(s => s.id === this.draggedSubtaskId); if (draggedIndex === -1) return; // Berechne neue Position basierend auf Drop-Position const rect = item.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; let newPosition = targetPosition; if (e.clientY > midpoint && draggedIndex < targetPosition) { // Nach unten, hinter das Ziel newPosition = targetPosition; } else if (e.clientY > midpoint && draggedIndex > targetPosition) { newPosition = targetPosition + 1; } else if (e.clientY <= midpoint && draggedIndex > targetPosition) { newPosition = targetPosition; } else if (e.clientY <= midpoint && draggedIndex < targetPosition) { newPosition = targetPosition - 1; } if (newPosition === draggedIndex) return; // Lokale Reihenfolge aktualisieren const [moved] = this.subtasks.splice(draggedIndex, 1); this.subtasks.splice(newPosition, 0, moved); this.renderSubtasks(); // API-Call if (this.mode === 'edit' && this.taskId) { const projectId = store.get('currentProjectId'); try { await api.reorderSubtasks(projectId, this.taskId, this.draggedSubtaskId, newPosition); } catch (error) { console.error('Fehler beim Neuordnen der Subtask:', error); } } } // Subtask bearbeiten startEditSubtask(subtaskId, titleElement) { const subtask = this.subtasks.find(s => s.id === subtaskId); if (!subtask) return; const currentTitle = subtask.title; const input = createElement('input', { type: 'text', className: 'subtask-edit-input', value: currentTitle }); // Titel durch Input ersetzen titleElement.replaceWith(input); input.focus(); input.select(); const saveEdit = async () => { const newTitle = input.value.trim(); if (newTitle && newTitle !== currentTitle) { subtask.title = newTitle; if (this.mode === 'edit' && this.taskId) { const projectId = store.get('currentProjectId'); try { await api.updateSubtask(projectId, this.taskId, subtaskId, { title: newTitle }); } catch (error) { console.error('Fehler beim Aktualisieren der Subtask:', error); subtask.title = currentTitle; // Rollback } } } this.renderSubtasks(); }; input.addEventListener('blur', saveEdit); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } else if (e.key === 'Escape') { subtask.title = currentTitle; // Keine Änderung this.renderSubtasks(); } }); } updateSubtaskProgress() { const progressContainer = $('#subtask-progress'); if (!progressContainer) return; if (this.subtasks.length === 0) { progressContainer.classList.add('hidden'); return; } const completed = this.subtasks.filter(s => s.completed).length; const total = this.subtasks.length; const percentage = Math.round((completed / total) * 100); progressContainer.classList.remove('hidden'); $('#subtask-progress-text').textContent = `${completed}/${total}`; $('#subtask-progress-bar').style.width = `${percentage}%`; } renderLinks() { const container = $('#links-container'); if (!container) return; clearElement(container); this.links.forEach(link => { const item = createElement('div', { className: 'link-item', dataset: { linkId: link.id } }, [ createElement('span', { className: 'link-icon' }, [this.getLinkIcon(link.icon)]), createElement('div', { className: 'link-info' }, [ createElement('div', { className: 'link-title' }, [link.title || link.url]), createElement('div', { className: 'link-url' }, [link.url]) ]), createElement('div', { className: 'link-actions' }, [ createElement('a', { href: link.url, target: '_blank', rel: 'noopener', className: 'btn btn-icon btn-ghost', title: 'Öffnen' }, ['↗']), createElement('button', { type: 'button', className: 'btn btn-icon btn-ghost', title: 'Löschen', onclick: () => this.deleteLink(link.id) }, ['×']) ]) ]); container.appendChild(item); }); } getLinkIcon(iconType) { const icons = { youtube: '▶', github: '⌘', docs: '📄', default: '🔗' }; return icons[iconType] || icons.default; } renderFiles() { const container = $('#attachments-container'); if (!container) return; clearElement(container); if (!this.files || this.files.length === 0) { container.appendChild(createElement('div', { className: 'no-files-message', style: { color: 'var(--text-muted)', fontSize: 'var(--text-sm)', padding: 'var(--spacing-2)' } }, ['Keine Anhänge'])); return; } this.files.forEach(file => { const projectId = store.get('currentProjectId'); // Support both camelCase (from backend) and snake_case (legacy) const originalName = file.originalName || file.original_name || 'Unbekannt'; const fileSize = file.sizeFormatted || formatFileSize(file.sizeBytes || file.size || 0); const fileIsImage = file.isImage !== undefined ? file.isImage : isImageFile(originalName); const previewContent = fileIsImage ? createElement('img', { src: api.getFilePreviewUrl(projectId, this.taskId, file.id), alt: originalName, onclick: () => this.openLightbox(file) }) : createElement('span', { className: 'icon' }, ['📎']); const item = createElement('div', { className: 'attachment-item', dataset: { fileId: file.id } }, [ createElement('div', { className: 'attachment-preview' }, [previewContent]), createElement('div', { className: 'attachment-info' }, [ createElement('div', { className: 'attachment-name' }, [originalName]), createElement('div', { className: 'attachment-size' }, [fileSize]) ]), createElement('div', { className: 'attachment-actions' }, [ createElement('button', { type: 'button', className: 'btn btn-icon btn-ghost', title: 'Herunterladen', onclick: () => this.downloadFile(file) }, ['↓']), createElement('button', { type: 'button', className: 'btn btn-icon btn-ghost', title: 'Löschen', onclick: () => this.deleteFile(file.id) }, ['×']) ]) ]); container.appendChild(item); }); } renderComments() { const container = $('#comments-container'); if (!container) return; clearElement(container); this.comments.forEach(comment => { const item = createElement('div', { className: 'comment-item', dataset: { commentId: comment.id } }, [ createElement('div', { className: 'comment-avatar' }, [ createElement('span', { className: 'avatar', style: { backgroundColor: comment.user?.color || '#888' } }, [getInitials(comment.user?.username || 'User')]) ]), createElement('div', { className: 'comment-content' }, [ createElement('div', { className: 'comment-header' }, [ createElement('span', { className: 'comment-author' }, [comment.user?.username || 'Unbekannt']), createElement('span', { className: 'comment-time' }, [formatRelativeTime(comment.created_at)]) ]), createElement('div', { className: 'comment-text' }, [this.formatCommentContent(comment.content)]) ]) ]); container.appendChild(item); }); } formatCommentContent(content) { // Convert @mentions to highlighted spans return content.replace(/@(\w+)/g, '@$1'); } renderHistory() { const container = $('#history-container'); if (!container) return; clearElement(container); this.history.forEach(entry => { const item = createElement('div', { className: 'history-item' }, [ createElement('span', { className: 'history-dot', style: { backgroundColor: entry.user?.color || '#888' } }), createElement('span', { className: 'history-text' }, [ createElement('strong', {}, [entry.user?.username || 'System']), ` ${this.formatHistoryAction(entry)}` ]), createElement('span', { className: 'history-time' }, [ formatRelativeTime(entry.created_at) ]) ]); container.appendChild(item); }); } formatHistoryAction(entry) { const actions = { created: 'hat die Aufgabe erstellt', updated: `hat ${entry.field} geändert`, moved: `hat die Aufgabe nach "${entry.new_value}" verschoben`, assigned: `hat die Aufgabe ${entry.new_value} zugewiesen`, unassigned: 'hat die Zuweisung entfernt', priority_changed: `hat die Priorität auf "${entry.new_value}" geändert`, due_date_changed: `hat das Fälligkeitsdatum auf ${formatDate(entry.new_value)} gesetzt`, completed: 'hat die Aufgabe abgeschlossen' }; return actions[entry.action] || entry.action; } // ===================== // SUBTASKS // ===================== async addSubtask() { const input = $('#subtask-input'); const title = input?.value.trim(); if (!title) return; if (this.mode === 'edit' && this.taskId) { try { const projectId = store.get('currentProjectId'); const subtask = await api.createSubtask(projectId, this.taskId, { title }); // Neue Subtask an erster Stelle einfügen this.subtasks.unshift(subtask); this.renderSubtasks(); input.value = ''; // Update subtask progress in store for immediate board update this.updateSubtaskProgressInStore(); } catch (error) { this.showError('Fehler beim Hinzufügen'); } } else { // For new tasks, store locally - an erster Stelle this.subtasks.unshift({ id: generateTempId(), title, completed: false }); this.renderSubtasks(); input.value = ''; } } async toggleSubtask(subtaskId) { const subtask = this.subtasks.find(s => s.id === subtaskId); if (!subtask) return; const wasCompleted = subtask.completed; subtask.completed = !subtask.completed; if (this.mode === 'edit' && this.taskId) { try { const projectId = store.get('currentProjectId'); await api.updateSubtask(projectId, this.taskId, subtaskId, { completed: subtask.completed }); // Wenn abgehakt: ans Ende der Liste verschieben if (subtask.completed && !wasCompleted) { const currentIndex = this.subtasks.findIndex(s => s.id === subtaskId); const lastPosition = this.subtasks.length - 1; if (currentIndex < lastPosition) { // Aus aktueller Position entfernen const [moved] = this.subtasks.splice(currentIndex, 1); // Ans Ende anfügen this.subtasks.push(moved); // API-Call für neue Position await api.reorderSubtasks(projectId, this.taskId, subtaskId, lastPosition); } } // Update subtask progress in store for immediate board update this.updateSubtaskProgressInStore(); } catch (error) { subtask.completed = wasCompleted; this.showError('Fehler beim Aktualisieren'); } } this.renderSubtasks(); } async deleteSubtask(subtaskId) { if (this.mode === 'edit' && this.taskId) { try { const projectId = store.get('currentProjectId'); await api.deleteSubtask(projectId, this.taskId, subtaskId); } catch (error) { this.showError('Fehler beim Löschen'); return; } } this.subtasks = this.subtasks.filter(s => s.id !== subtaskId); this.renderSubtasks(); // Update subtask progress in store for immediate board update if (this.mode === 'edit' && this.taskId) { this.updateSubtaskProgressInStore(); } } updateSubtaskProgressInStore() { const total = this.subtasks.length; const completed = this.subtasks.filter(s => s.completed).length; store.updateTask(this.taskId, { subtasks: this.subtasks, subtaskProgress: { total, completed } }); } // ===================== // LINKS // ===================== // Normalize URL - add https:// if no protocol specified normalizeUrl(url) { if (!url) return url; const trimmed = url.trim(); // Check if URL already has a protocol if (/^https?:\/\//i.test(trimmed)) { return trimmed; } // Add https:// prefix return 'https://' + trimmed; } async addLink() { const urlInput = $('#link-url'); const titleInput = $('#link-title'); let url = urlInput?.value.trim(); const title = titleInput?.value.trim(); if (!url) return; // Normalize URL (add https:// if missing) url = this.normalizeUrl(url); if (this.mode === 'edit' && this.taskId) { try { const projectId = store.get('currentProjectId'); const link = await api.createLink(projectId, this.taskId, { url, title }); this.links.push(link); this.renderLinks(); urlInput.value = ''; titleInput.value = ''; // Update link count in store for immediate board update store.updateTask(this.taskId, { linkCount: this.links.length }); } catch (error) { this.showError('Fehler beim Hinzufügen'); } } } async deleteLink(linkId) { if (this.mode === 'edit' && this.taskId) { try { const projectId = store.get('currentProjectId'); await api.deleteLink(projectId, this.taskId, linkId); } catch (error) { this.showError('Fehler beim Löschen'); return; } } this.links = this.links.filter(l => l.id !== linkId); this.renderLinks(); // Update link count in store for immediate board update if (this.mode === 'edit' && this.taskId) { store.updateTask(this.taskId, { linkCount: this.links.length }); } } // ===================== // FILES // ===================== async handleFileSelect(files) { if (!files || files.length === 0) return; for (const file of files) { if (file.size > 15 * 1024 * 1024) { this.showError(`${file.name} ist zu groß (max. 15 MB)`); continue; } if (this.mode === 'edit' && this.taskId) { await this.uploadFile(file); } } // Reset file input $('#file-input').value = ''; } async uploadFile(file) { const projectId = store.get('currentProjectId'); try { const result = await api.uploadTaskFile(projectId, this.taskId, file, (progress) => { // Could show upload progress here console.log(`Upload progress: ${progress}%`); }); // Backend returns { attachments: [...] } if (result.attachments && result.attachments.length > 0) { this.files.push(...result.attachments); } this.renderFiles(); // Update attachment count in store for immediate board update store.updateTask(this.taskId, { attachmentCount: this.files.length }); this.showSuccess('Datei hochgeladen'); } catch (error) { console.error('Upload error:', error); this.showError('Fehler beim Hochladen'); } } async downloadFile(file) { const projectId = store.get('currentProjectId'); const fileName = file.originalName || file.original_name; try { await api.downloadTaskFile(projectId, this.taskId, file.id, fileName); } catch (error) { this.showError('Fehler beim Herunterladen'); } } async deleteFile(fileId) { window.dispatchEvent(new CustomEvent('confirm:show', { detail: { message: 'Möchten Sie diese Datei wirklich löschen?', confirmText: 'Löschen', confirmClass: 'btn-danger', onConfirm: async () => { try { const projectId = store.get('currentProjectId'); await api.deleteFile(projectId, this.taskId, fileId); this.files = this.files.filter(f => f.id !== fileId); this.renderFiles(); // Update attachment count in store for immediate board update store.updateTask(this.taskId, { attachmentCount: this.files.length }); } catch (error) { this.showError('Fehler beim Löschen'); } } } })); } openLightbox(file) { const projectId = store.get('currentProjectId'); const imageUrl = api.getFilePreviewUrl(projectId, this.taskId, file.id); window.dispatchEvent(new CustomEvent('lightbox:open', { detail: { imageUrl, filename: file.original_name } })); } // ===================== // COMMENTS // ===================== async addComment() { const input = $('#comment-input'); const content = input?.value.trim(); if (!content || !this.taskId) return; try { const projectId = store.get('currentProjectId'); const comment = await api.createComment(projectId, this.taskId, { content }); this.comments.push(comment); this.renderComments(); input.value = ''; // Update comment count in store for immediate board update store.updateTask(this.taskId, { commentCount: this.comments.length }); // Notify typing stopped syncManager.notifyTyping(this.taskId, false); } catch (error) { this.showError('Fehler beim Hinzufügen'); } } // ===================== // LABELS // ===================== openLabelModal() { window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'label-modal', mode: 'create' } })); } async updateLabels() { if (this.mode !== 'edit' || !this.taskId) return; const labelIds = this.getSelectedLabels(); try { const projectId = store.get('currentProjectId'); await api.updateTask(projectId, this.taskId, { label_ids: labelIds }); } catch (error) { this.showError('Fehler beim Aktualisieren der Labels'); } } // ===================== // HELPERS // ===================== showError(message) { window.dispatchEvent(new CustomEvent('toast:show', { detail: { message, type: 'error' } })); } showSuccess(message) { window.dispatchEvent(new CustomEvent('toast:show', { detail: { message, type: 'success' } })); } } // Create and export singleton const taskModalManager = new TaskModalManager(); export default taskModalManager;