Initial commit
Dieser Commit ist enthalten in:
641
frontend/js/list.js
Normale Datei
641
frontend/js/list.js
Normale Datei
@ -0,0 +1,641 @@
|
||||
/**
|
||||
* 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;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren