/**
* TASKMATE - List View Module
* ===========================
* Tabellarische Listenansicht der Aufgaben
* Unterstützt gruppierte und flache Ansicht mit Inline-Bearbeitung
*/
import store from './store.js';
import api from './api.js';
import {
$, $$, createElement, clearElement, formatDate,
getDueDateStatus, filterTasks, getInitials, hexToRgba,
getContrastColor, groupBy, sortBy, escapeHtml
} from './utils.js';
class ListViewManager {
constructor() {
// DOM Elements
this.container = null;
this.contentElement = null;
this.sortSelect = null;
this.sortDirectionBtn = null;
// State
this.viewMode = 'grouped'; // 'grouped' | 'flat'
this.sortColumn = 'dueDate';
this.sortDirection = 'asc';
this.collapsedGroups = new Set();
// Inline editing state
this.editingCell = null;
this.init();
}
init() {
this.container = $('#view-list');
this.contentElement = $('#list-content');
this.sortSelect = $('#list-sort-select');
this.sortDirectionBtn = $('#list-sort-direction');
if (!this.container) return;
this.bindEvents();
// Subscribe to store changes for real-time updates
store.subscribe('tasks', () => this.render());
store.subscribe('columns', () => this.render());
store.subscribe('filters', () => this.render());
store.subscribe('searchResultIds', () => this.render());
store.subscribe('users', () => this.render());
store.subscribe('labels', () => this.render());
store.subscribe('currentView', (view) => {
if (view === 'list') this.render();
});
// Listen for app refresh events
window.addEventListener('app:refresh', () => this.render());
}
bindEvents() {
// View mode toggle
$$('.list-toggle-btn', this.container).forEach(btn => {
btn.addEventListener('click', () => this.setViewMode(btn.dataset.mode));
});
// Sort select
if (this.sortSelect) {
this.sortSelect.addEventListener('change', () => {
this.sortColumn = this.sortSelect.value;
this.render();
});
}
// Sort direction button
if (this.sortDirectionBtn) {
this.sortDirectionBtn.addEventListener('click', () => {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc');
this.render();
});
}
// Delegate click events on content
if (this.contentElement) {
this.contentElement.addEventListener('click', (e) => this.handleContentClick(e));
this.contentElement.addEventListener('change', (e) => this.handleContentChange(e));
this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
// Stop editing when clicking outside
document.addEventListener('click', (e) => {
if (this.editingCell && !this.editingCell.contains(e.target)) {
this.stopEditing();
}
});
}
}
setViewMode(mode) {
this.viewMode = mode;
// Update toggle buttons
$$('.list-toggle-btn', this.container).forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === mode);
});
this.render();
}
// =====================
// RENDERING
// =====================
render() {
if (!this.contentElement) return;
if (store.get('currentView') !== 'list') return;
const tasks = this.getFilteredAndSortedTasks();
if (tasks.length === 0) {
this.renderEmpty();
return;
}
if (this.viewMode === 'grouped') {
this.renderGrouped(tasks);
} else {
this.renderFlat(tasks);
}
}
getFilteredAndSortedTasks() {
const tasks = store.get('tasks').filter(t => !t.archived);
const filters = store.get('filters');
const searchResultIds = store.get('searchResultIds') || [];
const columns = store.get('columns');
// Apply filters
let filtered = filterTasks(tasks, filters, searchResultIds, columns);
// Apply sorting
filtered = this.sortTasks(filtered);
return filtered;
}
sortTasks(tasks) {
const columns = store.get('columns');
const users = store.get('users');
return sortBy(tasks, (task) => {
switch (this.sortColumn) {
case 'title':
return task.title?.toLowerCase() || '';
case 'priority':
const priorityOrder = { high: 0, medium: 1, low: 2 };
return priorityOrder[task.priority] ?? 1;
case 'dueDate':
return task.dueDate ? new Date(task.dueDate).getTime() : Infinity;
case 'status':
const colIndex = columns.findIndex(c => c.id === task.columnId);
return colIndex >= 0 ? colIndex : Infinity;
case 'assignee':
const user = users.find(u => u.id === task.assignedTo);
return user?.displayName?.toLowerCase() || 'zzz';
default:
return task.title?.toLowerCase() || '';
}
}, this.sortDirection);
}
renderEmpty() {
this.contentElement.innerHTML = `
Keine Aufgaben gefunden
Erstellen Sie eine neue Aufgabe oder ändern Sie die Filter.
`;
}
renderGrouped(tasks) {
const columns = store.get('columns');
const tasksByColumn = groupBy(tasks, 'columnId');
clearElement(this.contentElement);
columns.forEach(column => {
const columnTasks = tasksByColumn[column.id] || [];
if (columnTasks.length === 0) return;
const isCollapsed = this.collapsedGroups.has(column.id);
const group = createElement('div', { className: 'list-group' });
// Group header
const header = createElement('div', {
className: `list-group-header ${isCollapsed ? 'collapsed' : ''}`,
dataset: { columnId: column.id }
});
header.innerHTML = `
${escapeHtml(column.name)}
${columnTasks.length} Aufgabe${columnTasks.length !== 1 ? 'n' : ''}
`;
header.addEventListener('click', () => this.toggleGroup(column.id));
group.appendChild(header);
// Group content (table)
const content = createElement('div', {
className: `list-group-content ${isCollapsed ? 'collapsed' : ''}`
});
// Table header
content.appendChild(this.renderTableHeader());
// Table rows
columnTasks.forEach(task => {
content.appendChild(this.renderTableRow(task, column));
});
group.appendChild(content);
this.contentElement.appendChild(group);
});
}
renderFlat(tasks) {
clearElement(this.contentElement);
const tableContainer = createElement('div', { className: 'list-table' });
// Table header
tableContainer.appendChild(this.renderTableHeader());
// Table rows
const columns = store.get('columns');
tasks.forEach(task => {
const column = columns.find(c => c.id === task.columnId);
tableContainer.appendChild(this.renderTableRow(task, column));
});
this.contentElement.appendChild(tableContainer);
}
renderTableHeader() {
const header = createElement('div', { className: 'list-table-header' });
const columnDefs = [
{ key: 'title', label: 'Aufgabe' },
{ key: 'status', label: 'Status' },
{ key: 'priority', label: 'Priorität' },
{ key: 'dueDate', label: 'Fällig' },
{ key: 'assignee', label: 'Zugewiesen' }
];
columnDefs.forEach(col => {
const isSorted = this.sortColumn === col.key;
const span = createElement('span', {
className: isSorted ? `sorted ${this.sortDirection}` : '',
dataset: { sortKey: col.key }
});
span.innerHTML = `
${col.label}
`;
span.addEventListener('click', () => {
if (this.sortColumn === col.key) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortColumn = col.key;
this.sortDirection = 'asc';
}
if (this.sortSelect) this.sortSelect.value = this.sortColumn;
if (this.sortDirectionBtn) {
this.sortDirectionBtn.classList.toggle('asc', this.sortDirection === 'asc');
}
this.render();
});
header.appendChild(span);
});
return header;
}
renderTableRow(task, column) {
const row = createElement('div', {
className: 'list-row',
dataset: { taskId: task.id }
});
// Title cell
row.appendChild(this.renderTitleCell(task, column));
// Status cell
row.appendChild(this.renderStatusCell(task, column));
// Priority cell
row.appendChild(this.renderPriorityCell(task));
// Due date cell
row.appendChild(this.renderDueDateCell(task));
// Assignee cell
row.appendChild(this.renderAssigneeCell(task));
return row;
}
renderTitleCell(task, column) {
const cell = createElement('div', { className: 'list-cell list-cell-title' });
// Color indicator
const colorDot = createElement('span', {
className: 'status-dot',
style: { backgroundColor: column?.color || '#6366F1' }
});
cell.appendChild(colorDot);
// Title text (clickable to open task)
const titleSpan = createElement('span', {
dataset: { action: 'open-task', taskId: task.id }
}, [escapeHtml(task.title)]);
cell.appendChild(titleSpan);
return cell;
}
renderStatusCell(task, column) {
const columns = store.get('columns');
const cell = createElement('div', { className: 'list-cell list-cell-status list-cell-editable' });
// Status dot
const dot = createElement('span', {
className: 'status-dot',
style: { backgroundColor: column?.color || '#6366F1' }
});
cell.appendChild(dot);
// Status dropdown
const select = createElement('select', {
dataset: { field: 'columnId', taskId: task.id }
});
columns.forEach(col => {
const option = createElement('option', {
value: col.id,
selected: col.id === task.columnId
}, [col.name]);
select.appendChild(option);
});
cell.appendChild(select);
return cell;
}
renderPriorityCell(task) {
const cell = createElement('div', {
className: `list-cell list-cell-priority ${task.priority || 'medium'} list-cell-editable`
});
const select = createElement('select', {
dataset: { field: 'priority', taskId: task.id }
});
const priorities = [
{ value: 'high', label: 'Hoch' },
{ value: 'medium', label: 'Mittel' },
{ value: 'low', label: 'Niedrig' }
];
priorities.forEach(p => {
const option = createElement('option', {
value: p.value,
selected: p.value === (task.priority || 'medium')
}, [p.label]);
select.appendChild(option);
});
cell.appendChild(select);
return cell;
}
renderDueDateCell(task) {
const status = getDueDateStatus(task.dueDate);
let className = 'list-cell list-cell-date list-cell-editable';
if (status === 'overdue') className += ' overdue';
else if (status === 'today') className += ' today';
const cell = createElement('div', { className });
const input = createElement('input', {
type: 'date',
value: task.dueDate ? this.formatDateForInput(task.dueDate) : '',
dataset: { field: 'dueDate', taskId: task.id }
});
cell.appendChild(input);
return cell;
}
renderAssigneeCell(task) {
const users = store.get('users');
const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' });
// Sammle alle zugewiesenen Benutzer aus assignees Array
const assignedUserIds = new Set();
// Verwende das assignees Array vom Backend
if (task.assignees && Array.isArray(task.assignees)) {
task.assignees.forEach(assignee => {
if (assignee && assignee.id) {
assignedUserIds.add(assignee.id);
}
});
}
// Fallback: Füge assigned_to hinzu falls assignees leer ist
if (assignedUserIds.size === 0 && task.assignedTo) {
assignedUserIds.add(task.assignedTo);
}
// Container für mehrere Avatare
const avatarContainer = createElement('div', { className: 'avatar-container' });
if (assignedUserIds.size > 0) {
// Erstelle Avatar für jeden zugewiesenen Benutzer
Array.from(assignedUserIds).forEach(userId => {
const user = users.find(u => u.id === userId);
if (user) {
const avatar = createElement('div', {
className: 'avatar',
style: { backgroundColor: user.color || '#6366F1' },
title: user.displayName // Tooltip zeigt Name beim Hover
}, [getInitials(user.displayName)]);
avatarContainer.appendChild(avatar);
}
});
} else {
// Placeholder für "nicht zugewiesen"
const placeholder = createElement('div', {
className: 'avatar avatar-empty',
title: 'Nicht zugewiesen'
}, ['?']);
avatarContainer.appendChild(placeholder);
}
cell.appendChild(avatarContainer);
// User dropdown (versteckt, nur für Bearbeitung)
const select = createElement('select', {
className: 'assignee-select hidden',
dataset: { field: 'assignedTo', taskId: task.id }
});
// Empty option
const emptyOption = createElement('option', { value: '' }, ['Nicht zugewiesen']);
select.appendChild(emptyOption);
users.forEach(user => {
const option = createElement('option', {
value: user.id,
selected: user.id === task.assignedTo
}, [user.displayName]);
select.appendChild(option);
});
cell.appendChild(select);
return cell;
}
// =====================
// EVENT HANDLERS
// =====================
handleContentClick(e) {
// Handle avatar click for assignee editing
if (e.target.classList.contains('avatar') || e.target.classList.contains('avatar-empty')) {
const cell = e.target.closest('.list-cell-assignee');
if (cell) {
this.startEditingAssignee(cell);
return;
}
}
// Handle click on avatar container (wenn man neben Avatar klickt)
if (e.target.classList.contains('avatar-container')) {
const cell = e.target.closest('.list-cell-assignee');
if (cell) {
this.startEditingAssignee(cell);
return;
}
}
const target = e.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
const taskId = target.dataset.taskId;
if (action === 'open-task' && taskId) {
this.openTask(parseInt(taskId));
}
}
/**
* Start editing assignee
*/
startEditingAssignee(cell) {
// Stop any current editing
this.stopEditing();
// Add editing class to show dropdown and hide avatar
cell.classList.add('editing');
// Focus the select element
const select = cell.querySelector('.assignee-select');
if (select) {
select.focus();
}
this.editingCell = cell;
}
/**
* Stop editing
*/
stopEditing() {
if (this.editingCell) {
this.editingCell.classList.remove('editing');
this.editingCell = null;
}
}
handleContentChange(e) {
const target = e.target;
const field = target.dataset.field;
const taskId = target.dataset.taskId;
if (field && taskId) {
this.updateTaskField(parseInt(taskId), field, target.value);
// Stop editing after change for assignee field
if (field === 'assignedTo') {
this.stopEditing();
}
}
}
handleDoubleClick(e) {
const titleCell = e.target.closest('.list-cell-title span[data-action="open-task"]');
if (titleCell) {
const taskId = titleCell.dataset.taskId;
if (taskId) {
this.startInlineEdit(parseInt(taskId), titleCell);
}
}
}
toggleGroup(columnId) {
if (this.collapsedGroups.has(columnId)) {
this.collapsedGroups.delete(columnId);
} else {
this.collapsedGroups.add(columnId);
}
// Update DOM without full re-render
const header = this.contentElement.querySelector(`.list-group-header[data-column-id="${columnId}"]`);
const content = header?.nextElementSibling;
if (header && content) {
header.classList.toggle('collapsed');
content.classList.toggle('collapsed');
}
}
// =====================
// INLINE EDITING
// =====================
startInlineEdit(taskId, element) {
if (this.editingCell) {
this.cancelInlineEdit();
}
const task = store.get('tasks').find(t => t.id === taskId);
if (!task) return;
this.editingCell = { taskId, element, originalValue: task.title };
const input = createElement('input', {
type: 'text',
className: 'list-inline-input',
value: task.title
});
input.addEventListener('blur', () => this.finishInlineEdit());
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.finishInlineEdit();
} else if (e.key === 'Escape') {
this.cancelInlineEdit();
}
});
element.textContent = '';
element.appendChild(input);
input.focus();
input.select();
}
async finishInlineEdit() {
if (!this.editingCell) return;
const { taskId, element } = this.editingCell;
const input = element.querySelector('input');
const newValue = input?.value?.trim();
if (newValue && newValue !== this.editingCell.originalValue) {
await this.updateTaskField(taskId, 'title', newValue);
}
this.editingCell = null;
this.render();
}
cancelInlineEdit() {
if (!this.editingCell) return;
const { element, originalValue } = this.editingCell;
element.textContent = originalValue;
this.editingCell = null;
}
// =====================
// API OPERATIONS
// =====================
async updateTaskField(taskId, field, value) {
const projectId = store.get('currentProjectId');
if (!projectId) return;
const task = store.get('tasks').find(t => t.id === taskId);
if (!task) return;
// Prepare update data
let updateData = {};
if (field === 'columnId') {
updateData.columnId = parseInt(value);
} else if (field === 'assignedTo') {
updateData.assignedTo = value ? parseInt(value) : null;
} else if (field === 'dueDate') {
updateData.dueDate = value || null;
} else if (field === 'priority') {
updateData.priority = value;
} else if (field === 'title') {
updateData.title = value;
}
// Optimistic update
const tasks = store.get('tasks').map(t => {
if (t.id === taskId) {
return { ...t, ...updateData };
}
return t;
});
store.set('tasks', tasks);
try {
await api.updateTask(projectId, taskId, updateData);
// Dispatch refresh event
window.dispatchEvent(new CustomEvent('app:refresh'));
} catch (error) {
console.error('Fehler beim Aktualisieren der Aufgabe:', error);
// Rollback on error
const originalTasks = store.get('tasks').map(t => {
if (t.id === taskId) {
return task;
}
return t;
});
store.set('tasks', originalTasks);
// Show error notification
window.dispatchEvent(new CustomEvent('toast:show', {
detail: {
type: 'error',
message: 'Fehler beim Speichern der Änderung'
}
}));
}
}
openTask(taskId) {
const task = store.get('tasks').find(t => t.id === taskId);
if (task) {
window.dispatchEvent(new CustomEvent('task:edit', { detail: { task } }));
}
}
// =====================
// UTILITIES
// =====================
formatDateForInput(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
// Use local date formatting (NOT toISOString!)
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}`;
}
}
// Create and export singleton instance
const listViewManager = new ListViewManager();
export default listViewManager;