1655 Zeilen
49 KiB
JavaScript
1655 Zeilen
49 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 {
|
|
// Create mode - clear all UI elements and set defaults
|
|
|
|
// Clear UI elements (render empty arrays)
|
|
this.renderSubtasks();
|
|
this.renderLinks();
|
|
this.renderFiles();
|
|
this.renderComments();
|
|
this.renderHistory();
|
|
|
|
// 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 => {
|
|
// DEBUG: User-Objekt anschauen
|
|
console.log('[DEBUG] User in Dropdown:', 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'}`
|
|
}, [user.initials || getInitials(user.display_name || user.email || 'XX')]);
|
|
|
|
// Probiere verschiedene Felder für den Namen
|
|
const displayName = user.displayName || user.display_name || user.name || user.username || 'Benutzer';
|
|
console.log('[DEBUG] Display name für user', user.id, ':', displayName);
|
|
const name = createElement('span', { class: 'multi-select-option-name' }, [displayName]);
|
|
|
|
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'}" title="${user.display_name || user.email}">
|
|
${user.initials || getInitials(user.display_name || user.email || 'XX')}
|
|
</span>
|
|
</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, 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, '<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 });
|
|
// 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;
|