Files
TaskMate/frontend/js/board.js
HG 9bf298c26b Statuskarten via Drag&Drop verschiebbar
Unteraufgaben lassen sich verschieben und bearbeiten
2025-12-30 19:55:39 +00:00

1456 Zeilen
44 KiB
JavaScript

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