| |
| |
| |
| |
|
|
| import { store } from '../state.js'; |
| import { Task } from '../models.js'; |
| import { Route } from '../router.js'; |
|
|
| export class TaskList { |
| constructor(projectId = null) { |
| this.projectId = projectId; |
| this.viewMode = 'list'; |
| this.draggedTask = null; |
| } |
|
|
| render(container) { |
| this.container = container; |
| this.update(); |
|
|
| |
| this.unsubscribers = [ |
| store.subscribe('tasks:changed', () => this.update()), |
| store.subscribe('projects:changed', () => this.update()), |
| store.subscribe('ui:searchQuery:changed', () => this.update()) |
| ]; |
| } |
|
|
| update() { |
| let tasks = this.projectId |
| ? store.getTasksByProject(this.projectId) |
| : store.getFilteredTasks(); |
|
|
| |
| if (!this.projectId && window.location.pathname === Route.TASKS) { |
| tasks = tasks.filter(t => t.assigneeId === store.state.currentUser?.id); |
| } |
|
|
| this.container.innerHTML = ` |
| <div class="animate-fade-in h-full flex flex-col"> |
| <!-- Header --> |
| <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6"> |
| <div> |
| <h1 class="text-2xl font-bold text-gray-900 dark:text-white"> |
| ${this.projectId ? this.getProjectName() : 'Meine Tasks'} |
| </h1> |
| <p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| ${tasks.length} Tasks insgesamt |
| </p> |
| </div> |
| |
| <div class="flex items-center space-x-3"> |
| <!-- View Toggle --> |
| <div class="bg-gray-100 dark:bg-slate-700 p-1 rounded-lg flex"> |
| ${['list', 'board'].map(mode => ` |
| <button onclick="window.setViewMode('${mode}')" |
| class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${this.viewMode === mode ? 'bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow-sm' : 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'}"> |
| <i data-lucide="${mode === 'list' ? 'list' : 'layout-grid'}" class="w-4 h-4"></i> |
| </button> |
| `).join('')} |
| </div> |
| |
| <button onclick="window.openTaskModal()" class="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> |
| <i data-lucide="plus" class="w-4 h-4 mr-2"></i> |
| Neuer Task |
| </button> |
| </div> |
| </div> |
| |
| <!-- Filters --> |
| <div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-slate-700 p-4 mb-6"> |
| <div class="flex flex-wrap gap-4 items-center"> |
| <div class="flex-1 min-w-[200px]"> |
| <div class="relative"> |
| <i data-lucide="search" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4"></i> |
| <input type="text" |
| value="${store.state.ui.searchQuery}" |
| oninput="window.updateSearch(this.value)" |
| placeholder="Tasks suchen..." |
| class="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:text-white"> |
| </div> |
| </div> |
| |
| <select onchange="window.updateFilter('priority', this.value)" class="border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 bg-white dark:bg-slate-700 text-sm dark:text-white"> |
| <option value="">Alle Prioritäten</option> |
| ${Object.values(Task.PRIORITY).map(p => ` |
| <option value="${p}" ${store.state.ui.filters.priority === p ? 'selected' : ''}>${this.capitalize(p)}</option> |
| `).join('')} |
| </select> |
| |
| <select onchange="window.updateFilter('status', this.value)" class="border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 bg-white dark:bg-slate-700 text-sm dark:text-white"> |
| <option value="">Alle Status</option> |
| ${Object.values(Task.STATUS).map(s => ` |
| <option value="${s}" ${store.state.ui.filters.status === s ? 'selected' : ''}>${this.formatStatus(s)}</option> |
| `).join('')} |
| </select> |
| |
| ${this.projectId ? '' : ` |
| <select onchange="window.updateFilter('project', this.value)" class="border border-gray-300 dark:border-slate-600 rounded-lg px-3 py-2 bg-white dark:bg-slate-700 text-sm dark:text-white"> |
| <option value="">Alle Projekte</option> |
| ${store.state.projects.map(p => ` |
| <option value="${p.id}" ${store.state.ui.filters.project === p.id ? 'selected' : ''}>${p.name}</option> |
| `).join('')} |
| </select> |
| `} |
| |
| <button onclick="window.clearFilters()" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> |
| <i data-lucide="x" class="w-4 h-4"></i> |
| </button> |
| </div> |
| </div> |
| |
| <!-- Content --> |
| <div class="flex-1 overflow-hidden"> |
| ${this.viewMode === 'board' ? this.renderBoardView(tasks) : this.renderListView(tasks)} |
| </div> |
| </div> |
| `; |
|
|
| lucide.createIcons(); |
| this.bindEvents(); |
| } |
|
|
| renderListView(tasks) { |
| if (tasks.length === 0) { |
| return this.renderEmptyState(); |
| } |
|
|
| const sortedTasks = this.sortTasks(tasks); |
|
|
| return ` |
| <div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 overflow-hidden"> |
| <div class="overflow-x-auto"> |
| <table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700"> |
| <thead class="bg-gray-50 dark:bg-slate-700/50"> |
| <tr> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-8"></th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Task</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Priorität</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Projekt</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Fällig</th> |
| <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Assignee</th> |
| <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Aktionen</th> |
| </tr> |
| </thead> |
| <tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700"> |
| ${sortedTasks.map(task => this.renderTaskRow(task)).join('')} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| `; |
| } |
|
|
| renderBoardView(tasks) { |
| const columns = [ |
| { id: 'todo', title: 'To Do', color: 'gray' }, |
| { id: 'in_progress', title: 'In Progress', color: 'blue' }, |
| { id: 'review', title: 'Review', color: 'yellow' }, |
| { id: 'done', title: 'Done', color: 'green' } |
| ]; |
|
|
| return ` |
| <div class="h-full overflow-x-auto custom-scrollbar"> |
| <div class="flex space-x-6 min-w-full h-full"> |
| ${columns.map(col => { |
| const colTasks = tasks.filter(t => t.status === col.id); |
| return ` |
| <div class="flex-1 min-w-[300px] max-w-[400px] flex flex-col"> |
| <div class="flex items-center justify-between mb-4"> |
| <div class="flex items-center"> |
| <div class="w-3 h-3 rounded-full bg-${col.color}-500 mr-2"></div> |
| <h3 class="font-semibold text-gray-900 dark:text-white">${col.title}</h3> |
| <span class="ml-2 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300 text-xs px-2 py-0.5 rounded-full">${colTasks.length}</span> |
| </div> |
| </div> |
| <div class="flex-1 space-y-3 overflow-y-auto custom-scrollbar pr-2" |
| ondrop="window.handleDrop(event, '${col.id}')" |
| ondragover="window.handleDragOver(event)" |
| data-status="${col.id}"> |
| ${colTasks.map(task => this.renderTaskCard(task)).join('')} |
| ${colTasks.length === 0 ? ` |
| <div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 text-center text-gray-400 dark:text-gray-500 text-sm"> |
| Drop tasks here |
| </div> |
| ` : ''} |
| </div> |
| </div> |
| `; |
| }).join('')} |
| </div> |
| </div> |
| `; |
| } |
|
|
| renderTaskRow(task) { |
| const project = store.state.projects.find(p => p.id === task.projectId); |
| const assignee = store.state.users.find(u => u.id === task.assigneeId); |
| const priorityColors = { |
| low: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', |
| medium: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', |
| high: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', |
| urgent: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' |
| }; |
|
|
| const statusColors = { |
| todo: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', |
| in_progress: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', |
| review: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', |
| done: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' |
| }; |
|
|
| return ` |
| <tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors group"> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| <button onclick="window.toggleTaskStatus('${task.id}')" |
| class="w-5 h-5 rounded border-2 ${task.status === 'done' ? 'bg-green-500 border-green-500' : 'border-gray-300 dark:border-slate-500 hover:border-primary-500'} flex items-center justify-center transition-colors"> |
| ${task.status === 'done' ? '<i data-lucide="check" class="w-3 h-3 text-white"></i>' : ''} |
| </button> |
| </td> |
| <td class="px-6 py-4"> |
| <div class="text-sm font-medium text-gray-900 dark:text-white ${task.status === 'done' ? 'line-through text-gray-400 dark:text-gray-500' : ''}">${task.title}</div> |
| <div class="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate max-w-xs">${task.description || 'Keine Beschreibung'}</div> |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| <span class="px-2 py-1 text-xs rounded-full ${statusColors[task.status]}">${this.formatStatus(task.status)}</span> |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| <span class="px-2 py-1 text-xs rounded-full ${priorityColors[task.priority]}">${this.capitalize(task.priority)}</span> |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| ${project ? ` |
| <div class="flex items-center"> |
| <span class="w-2 h-2 rounded-full mr-2" style="background-color: ${project.color}"></span> |
| <span class="text-sm text-gray-600 dark:text-gray-300">${project.name}</span> |
| </div> |
| ` : '-'} |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| ${task.dueDate ? ` |
| <div class="flex items-center text-sm ${task.isOverdue ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-600 dark:text-gray-300'}"> |
| <i data-lucide="calendar" class="w-3 h-3 mr-1"></i> |
| ${new Date(task.dueDate).toLocaleDateString('de-DE')} |
| ${task.isOverdue ? '<span class="ml-1 text-xs">(überfällig)</span>' : ''} |
| </div> |
| ` : '-'} |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap"> |
| ${assignee ? ` |
| <img src="${assignee.avatar}" alt="${assignee.name}" class="w-8 h-8 rounded-full" title="${assignee.name}"> |
| ` : '-'} |
| </td> |
| <td class="px-6 py-4 whitespace-nowrap text-right"> |
| <button onclick="window.editTask('${task.id}')" class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 mr-3"> |
| <i data-lucide="pencil" class="w-4 h-4"></i> |
| </button> |
| <button onclick="window.deleteTask('${task.id}')" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"> |
| <i data-lucide="trash-2" class="w-4 h-4"></i> |
| </button> |
| </td> |
| </tr> |
| `; |
| } |
|
|
| renderTaskCard(task) { |
| const project = store.state.projects.find(p => p.id === task.projectId); |
| const assignee = store.state.users.find(u => u.id === task.assigneeId); |
| const priorityColors = { |
| low: 'border-l-4 border-gray-400', |
| medium: 'border-l-4 border-blue-500', |
| high: 'border-l-4 border-orange-500', |
| urgent: 'border-l-4 border-red-500' |
| }; |
|
|
| return ` |
| <div class="task-card bg-white dark:bg-slate-700 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-slate-600 ${priorityColors[task.priority]} cursor-pointer" |
| draggable="true" |
| ondragstart="window.handleDragStart(event, '${task.id}')" |
| onclick="window.editTask('${task.id}')"> |
| <div class="flex justify-between items-start mb-2"> |
| <h4 class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2">${task.title}</h4> |
| ${task.isOverdue ? '<span class="text-xs text-red-500 font-semibold">!</span>' : ''} |
| </div> |
| <div class="flex items-center justify-between mt-3"> |
| <div class="flex items-center space-x-2"> |
| ${assignee ? `<img src="${assignee.avatar}" class="w-6 h-6 rounded-full" title="${assignee.name}">` : ''} |
| ${task.dueDate ? ` |
| <span class="text-xs ${task.isOverdue ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'} flex items-center"> |
| <i data-lucide="clock" class="w-3 h-3 mr-1"></i> |
| ${new Date(task.dueDate).toLocaleDateString('de-DE', {month:'short', day:'numeric'})} |
| </span> |
| ` : ''} |
| </div> |
| ${project ? `<span class="w-2 h-2 rounded-full" style="background-color: ${project.color}" title="${project.name}"></span>` : ''} |
| </div> |
| </div> |
| `; |
| } |
|
|
| renderEmptyState() { |
| return ` |
| <div class="text-center py-12 bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-slate-700"> |
| <i data-lucide="inbox" class="w-12 h-12 mx-auto text-gray-400 mb-4"></i> |
| <h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">Keine Tasks gefunden</h3> |
| <p class="text-gray-500 dark:text-gray-400 mb-4">Erstellen Sie einen neuen Task oder passen Sie die Filter an.</p> |
| <button onclick="window.openTaskModal()" class="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700"> |
| <i data-lucide="plus" class="w-4 h-4 mr-2"></i> |
| Neuer Task |
| </button> |
| </div> |
| `; |
| } |
|
|
| getProjectName() { |
| const project = store.state.projects.find(p => p.id === this.projectId); |
| return project ? project.name : 'Unbekanntes Projekt'; |
| } |
|
|
| sortTasks(tasks) { |
| return tasks.sort((a, b) => { |
| |
| if (a.isOverdue && !b.isOverdue) return -1; |
| if (!a.isOverdue && b.isOverdue) return 1; |
| |
| |
| const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }; |
| if (priorityOrder[a.priority] !== priorityOrder[b.priority]) { |
| return priorityOrder[a.priority] - priorityOrder[b.priority]; |
| } |
| |
| |
| if (a.dueDate && b.dueDate) { |
| return new Date(a.dueDate) - new Date(b.dueDate); |
| } |
| |
| return 0; |
| }); |
| } |
|
|
| capitalize(str) { |
| return str.charAt(0).toUpperCase() + str.slice(1); |
| } |
|
|
| formatStatus(status) { |
| const labels = { |
| todo: 'To Do', |
| in_progress: 'In Progress', |
| review: 'Review', |
| done: 'Done' |
| }; |
| return labels[status] || status; |
| } |
|
|
| bindEvents() { |
| |
| window.setViewMode = (mode) => { |
| this.viewMode = mode; |
| this.update(); |
| }; |
|
|
| |
| window.updateSearch = (value) => { |
| store.setUISState('searchQuery', value); |
| }; |
|
|
| window.updateFilter = (type, value) => { |
| const filters = { ...store.state.ui.filters, [type]: value || null }; |
| store.setUISState('filters', filters); |
| }; |
|
|
| window.clearFilters = () => { |
| store.setUISState('searchQuery', ''); |
| store.setUISState('filters', { priority: null, status: null, assignee: null, project: null }); |
| }; |
|
|
| |
| window.openTaskModal = () => { |
| const title = prompt('Task Titel:'); |
| if (!title) return; |
| |
| const description = prompt('Beschreibung:') || ''; |
| const priority = prompt('Priorität (low, medium, high, urgent):', 'medium') || 'medium'; |
| |
| store.addTask({ |
| title, |
| description, |
| projectId: this.projectId || (store.state.projects[0]?.id || null), |
| assigneeId: store.state.currentUser?.id, |
| creatorId: store.state.currentUser?.id, |
| priority: ['low', 'medium', 'high', 'urgent'].includes(priority) ? priority : 'medium', |
| status: 'todo', |
| dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) |
| }); |
| }; |
|
|
| window.editTask = (taskId) => { |
| const task = store.state.tasks.find(t => t.id === taskId); |
| if (!task) return; |
| |
| const title = prompt('Neuer Titel:', task.title); |
| if (title === null) return; |
| |
| const status = prompt('Status (todo, in_progress, review, done):', task.status); |
| if (status === null) return; |
| |
| const priority = prompt('Priorität (low, medium, high, urgent):', task.priority); |
| if (priority === null) return; |
| |
| const updates = { title }; |
| if (['todo', 'in_progress', 'review', 'done'].includes(status)) updates.status = status; |
| if (['low', 'medium', 'high', 'urgent'].includes(priority)) updates.priority = priority; |
| |
| store.updateTask(taskId, updates); |
| }; |
|
|
| window.deleteTask = (taskId) => { |
| if (confirm('Task wirklich löschen?')) { |
| store.deleteTask(taskId); |
| } |
| }; |
|
|
| window.toggleTaskStatus = (taskId) => { |
| const task = store.state.tasks.find(t => t.id === taskId); |
| if (task) { |
| const newStatus = task.status === 'done' ? 'todo' : 'done'; |
| store.updateTask(taskId, { status: newStatus }); |
| } |
| }; |
|
|
| |
| window.handleDragStart = (e, taskId) => { |
| this.draggedTask = taskId; |
| e.dataTransfer.effectAllowed = 'move'; |
| e.target.style.opacity = '0.5'; |
| }; |
|
|
| window.handleDragOver = (e) => { |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = 'move'; |
| e.currentTarget.classList.add('bg-gray-100', 'dark:bg-slate-700/50'); |
| }; |
|
|
| window.handleDrop = (e, status) => { |
| e.preventDefault(); |
| e.currentTarget.classList.remove('bg-gray-100', 'dark:bg-slate-700/50'); |
| const taskId = this.draggedTask; |
| if (taskId) { |
| store.moveTask(taskId, status); |
| this.draggedTask = null; |
| } |
| |
| document.querySelectorAll('[draggable="true"]').forEach(el => el.style.opacity = '1'); |
| }; |
| } |
|
|
| destroy() { |
| this.unsubscribers.forEach(unsub => unsub()); |
| |
| delete window.setViewMode; |
| delete window.updateSearch; |
| delete window.updateFilter; |
| delete window.clearFilters; |
| delete window.openTaskModal; |
| delete window.editTask; |
| delete window.deleteTask; |
| delete window.toggleTaskStatus; |
| delete window.handleDragStart; |
| delete window.handleDragOver; |
| delete window.handleDrop; |
| } |
| } |