642 Zeilen
18 KiB
JavaScript
642 Zeilen
18 KiB
JavaScript
/**
|
|
* 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));
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="list-empty">
|
|
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
<h3>Keine Aufgaben gefunden</h3>
|
|
<p>Erstellen Sie eine neue Aufgabe oder ändern Sie die Filter.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
<span class="list-group-color" style="background-color: ${column.color || '#6366F1'}"></span>
|
|
<span class="list-group-title">${escapeHtml(column.name)}</span>
|
|
<span class="list-group-count">${columnTasks.length} Aufgabe${columnTasks.length !== 1 ? 'n' : ''}</span>
|
|
`;
|
|
|
|
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}
|
|
<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
`;
|
|
|
|
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' });
|
|
|
|
const assignedUser = users.find(u => u.id === task.assignedTo);
|
|
|
|
// Avatar
|
|
if (assignedUser) {
|
|
const avatar = createElement('div', {
|
|
className: 'avatar',
|
|
style: { backgroundColor: assignedUser.color || '#6366F1' }
|
|
}, [getInitials(assignedUser.displayName)]);
|
|
cell.appendChild(avatar);
|
|
}
|
|
|
|
// User dropdown
|
|
const select = createElement('select', {
|
|
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) {
|
|
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));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|