1601 Zeilen
49 KiB
JavaScript
1601 Zeilen
49 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;
|
|
|
|
// Layout preferences
|
|
this.multiColumnLayout = this.loadLayoutPreference();
|
|
|
|
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();
|
|
});
|
|
|
|
// Apply initial layout preference
|
|
setTimeout(() => {
|
|
this.applyLayoutClass();
|
|
if (this.multiColumnLayout) {
|
|
this.checkAndApplyDynamicLayout();
|
|
}
|
|
}, 100);
|
|
|
|
// Re-check layout on window resize
|
|
window.addEventListener('resize', debounce(() => {
|
|
if (this.multiColumnLayout) {
|
|
this.checkAndApplyDynamicLayout();
|
|
}
|
|
}, 250));
|
|
}
|
|
|
|
// =====================
|
|
// 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));
|
|
|
|
// Layout toggle button - use delegated event handling
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.closest('#btn-toggle-layout')) {
|
|
this.toggleLayout();
|
|
}
|
|
});
|
|
}
|
|
|
|
// =====================
|
|
// RENDERING
|
|
// =====================
|
|
|
|
render() {
|
|
if (!this.boardElement) return;
|
|
|
|
const columns = store.get('columns');
|
|
clearElement(this.boardElement);
|
|
|
|
// Apply layout class
|
|
this.applyLayoutClass();
|
|
|
|
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);
|
|
|
|
// Check dynamic layout after render
|
|
setTimeout(() => this.checkAndApplyDynamicLayout(), 100);
|
|
}
|
|
|
|
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';
|
|
|
|
// Initialen berechnen - currentUser hat immer die korrekten initials
|
|
let initials = currentUser?.initials || getInitials(name) || '?';
|
|
|
|
// Sicherheit: Falls initials undefined ist, Fallback verwenden
|
|
if (!initials || initials === 'undefined') {
|
|
initials = getInitials(currentUser?.email || name) || '?';
|
|
}
|
|
|
|
const avatar = createElement('span', {
|
|
className: 'avatar task-assignee-avatar stacked',
|
|
style: {
|
|
backgroundColor: color,
|
|
zIndex: 10 - index
|
|
},
|
|
title: name
|
|
}, [initials]);
|
|
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?.display_name || assignedUser?.username || task.assignedName || 'Benutzer';
|
|
|
|
// Initialen berechnen
|
|
let initials = assignedUser?.initials || getInitials(currentName) || '?';
|
|
if (!initials || initials === 'undefined') {
|
|
initials = getInitials(assignedUser?.email || currentName) || '?';
|
|
}
|
|
|
|
const assignee = createElement('div', { className: 'task-assignees' }, [
|
|
createElement('span', {
|
|
className: 'avatar task-assignee-avatar',
|
|
style: { backgroundColor: currentColor },
|
|
title: currentName
|
|
}, [initials])
|
|
]);
|
|
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();
|
|
}
|
|
});
|
|
|
|
// Check if dynamic layout adjustment is needed
|
|
setTimeout(() => this.checkAndApplyDynamicLayout(), 100);
|
|
}
|
|
|
|
// =====================
|
|
// 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;
|
|
}
|
|
|
|
// =====================
|
|
// LAYOUT PREFERENCES
|
|
// =====================
|
|
|
|
loadLayoutPreference() {
|
|
const stored = localStorage.getItem('taskmate:boardLayout');
|
|
return stored === 'multiColumn';
|
|
}
|
|
|
|
saveLayoutPreference(multiColumn) {
|
|
localStorage.setItem('taskmate:boardLayout', multiColumn ? 'multiColumn' : 'single');
|
|
}
|
|
|
|
toggleLayout() {
|
|
this.multiColumnLayout = !this.multiColumnLayout;
|
|
this.saveLayoutPreference(this.multiColumnLayout);
|
|
this.applyLayoutClass();
|
|
this.checkAndApplyDynamicLayout();
|
|
|
|
this.showSuccess(this.multiColumnLayout
|
|
? 'Mehrspalten-Layout aktiviert'
|
|
: 'Einspalten-Layout aktiviert'
|
|
);
|
|
}
|
|
|
|
applyLayoutClass() {
|
|
const toggleBtn = $('#btn-toggle-layout');
|
|
|
|
if (this.multiColumnLayout) {
|
|
this.boardElement?.classList.add('multi-column-layout');
|
|
toggleBtn?.classList.add('active');
|
|
} else {
|
|
this.boardElement?.classList.remove('multi-column-layout');
|
|
toggleBtn?.classList.remove('active');
|
|
|
|
// Remove all dynamic classes when disabled
|
|
const columns = this.boardElement?.querySelectorAll('.column');
|
|
columns?.forEach(column => {
|
|
const columnBody = column.querySelector('.column-body');
|
|
columnBody?.classList.remove('dynamic-2-columns', 'dynamic-3-columns');
|
|
column.classList.remove('expanded-2x', 'expanded-3x');
|
|
});
|
|
}
|
|
}
|
|
|
|
checkAndApplyDynamicLayout() {
|
|
if (!this.multiColumnLayout || !this.boardElement) return;
|
|
|
|
// Debug logging
|
|
console.log('[Layout Check] Checking dynamic layout...');
|
|
|
|
// Check each column to see if scrolling is needed
|
|
const columns = this.boardElement.querySelectorAll('.column');
|
|
|
|
columns.forEach(column => {
|
|
const columnBody = column.querySelector('.column-body');
|
|
if (!columnBody) return;
|
|
|
|
// Remove dynamic classes first
|
|
columnBody.classList.remove('dynamic-2-columns', 'dynamic-3-columns');
|
|
column.classList.remove('expanded-2x', 'expanded-3x');
|
|
|
|
// Force reflow to get accurate measurements
|
|
columnBody.offsetHeight;
|
|
|
|
const scrollHeight = columnBody.scrollHeight;
|
|
const clientHeight = columnBody.clientHeight;
|
|
const hasOverflow = scrollHeight > clientHeight;
|
|
|
|
console.log('[Layout Check]', {
|
|
column: column.dataset.columnId,
|
|
scrollHeight,
|
|
clientHeight,
|
|
hasOverflow,
|
|
ratio: scrollHeight / clientHeight
|
|
});
|
|
|
|
// Check if content overflows
|
|
if (hasOverflow && clientHeight > 0) {
|
|
// Calculate how many columns we need based on content height
|
|
const ratio = scrollHeight / clientHeight;
|
|
|
|
if (ratio > 2.5 && window.innerWidth >= 1800) {
|
|
// Need 3 columns
|
|
console.log('[Layout] Applying 3 columns');
|
|
columnBody.classList.add('dynamic-3-columns');
|
|
column.classList.add('expanded-3x');
|
|
} else if (ratio > 1.1 && window.innerWidth >= 1400) {
|
|
// Need 2 columns (reduced threshold to 1.1)
|
|
console.log('[Layout] Applying 2 columns');
|
|
columnBody.classList.add('dynamic-2-columns');
|
|
column.classList.add('expanded-2x');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Create and export singleton
|
|
const boardManager = new BoardManager();
|
|
|
|
export default boardManager;
|