Files
TaskMate/frontend/js/task-modal.js
Claude Project Manager ab1e5be9a9 Initial commit
2025-12-28 21:36:45 +00:00

1466 Zeilen
42 KiB
JavaScript

/**
* 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 = `
<span class="multi-select-placeholder">Mitarbeitende auswählen...</span>
<span class="multi-select-arrow">▼</span>
`;
} else {
const tags = selectedUsers.map(user => `
<span class="multi-select-tag">
<span class="multi-select-tag-avatar" style="background-color: ${user.color || '#6366F1'}">
${getInitials(user.display_name || user.username)}
</span>
${user.display_name || user.username}
</span>
`).join('');
trigger.innerHTML = `
<div class="multi-select-selected">${tags}</div>
<span class="multi-select-arrow">▼</span>
`;
}
}
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 => {
const item = createElement('div', {
className: `subtask-item ${subtask.completed ? 'completed' : ''}`,
dataset: { subtaskId: subtask.id }
}, [
createElement('input', {
type: 'checkbox',
checked: subtask.completed,
onchange: () => this.toggleSubtask(subtask.id)
}),
createElement('span', {
className: 'subtask-title'
}, [subtask.title]),
createElement('button', {
type: 'button',
className: 'subtask-delete',
onclick: () => this.deleteSubtask(subtask.id)
}, ['×'])
]);
container.appendChild(item);
});
// Update progress display
this.updateSubtaskProgress();
}
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, '<span class="mention">@$1</span>');
}
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 });
this.subtasks.push(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
this.subtasks.push({
id: generateTempId(),
title,
completed: false
});
this.renderSubtasks();
input.value = '';
}
}
async toggleSubtask(subtaskId) {
const subtask = this.subtasks.find(s => s.id === subtaskId);
if (!subtask) return;
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
});
// Update subtask progress in store for immediate board update
this.updateSubtaskProgressInStore();
} catch (error) {
subtask.completed = !subtask.completed;
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;