From 7f9a2bca503451886e70a0db598a795727b42445 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 21 Aug 2026 13:48:10 +0200 Subject: [PATCH] feat(UI-Overhaul-Phase4): Tasks UI 3-Spalten Layout - Complete rebuild of Tasks.tsx with 3-column explorer layout - Left: TaskTree with status/priority grouping, expandable sections - Middle: List view or Kanban board (switchable via toolbar) - Right: TaskDetail panel with status selector, edit/delete actions - PluginToolbar registration with new-task button and view-mode selector - ResizablePanel for tree and detail columns - Mobile responsive with single-pane view switching - Search bar in list view - Kanban board with 4 status columns (Offen, In Bearbeitung, Blockiert, Erledigt) tsc clean --- frontend/src/pages/Tasks.tsx | 591 +++++++++++++++++++---------------- 1 file changed, 318 insertions(+), 273 deletions(-) diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index 9a11ef3..7c404c2 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -1,10 +1,12 @@ /** - * Tasks page — list with filter, create modal, detail. + * Tasks page — 3-column explorer layout (tree + list + detail). + * Rebuilt for UI-Overhaul Phase 4. */ import { asError } from '@/utils/errorTypes'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { ResizablePanel } from '@/components/ui/ResizablePanel'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; @@ -12,7 +14,8 @@ import { Modal } from '@/components/ui/Modal'; import { Badge } from '@/components/ui/Badge'; import { EmptyState } from '@/components/ui/EmptyState'; import { useToast } from '@/components/ui/Toast'; -import { Loader2, Plus, CheckSquare, Clock, AlertCircle } from 'lucide-react'; +import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; +import { Loader2, Plus, CheckSquare, Clock, AlertCircle, ChevronDown, ChevronRight, List, LayoutGrid } from 'lucide-react'; import { useTasks, useCreateTask, @@ -24,10 +27,13 @@ import { type TaskStatus, } from '@/api/tasks'; -const STATUS_COLORS: Record = { +const STATUS_COLORS: Record = { open: 'secondary', in_progress: 'info', + review: 'warning', + blocked: 'danger', done: 'success', + cancelled: 'secondary', }; const PRIORITY_COLORS: Record = { @@ -37,27 +43,184 @@ const PRIORITY_COLORS: Record void; + filter: TaskFilter; + setFilter: (f: TaskFilter) => void; +} + +function TaskTree({ tasks, selectedTaskId, onSelect, filter, setFilter }: TaskTreeProps) { + const { t } = useTranslation(); + const [treeMode, setTreeMode] = useState<'status' | 'priority'>('status'); + const [expanded, setExpanded] = useState>(new Set(['open', 'in_progress', 'urgent', 'high'])); + + const toggle = (key: string) => setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + + const grouped = useMemo(() => { + if (treeMode === 'status') { + return STATUS_GROUPS.map(g => ({ + key: g.key, + label: g.label, + items: tasks.filter(t => g.statuses.includes(t.status)), + })); + } + return PRIORITY_GROUPS.map(g => ({ + key: g.key, + label: g.label, + items: tasks.filter(t => t.priority === g.key), + })); + }, [tasks, treeMode]); + + return ( +
+
+

+ + {t('tasks.title')} +

+
+
+
+ + +
+
+
+ + {grouped.map(group => ( +
+
toggle(group.key)} + > + {expanded.has(group.key) ? : } + {group.label} + {group.items.length} +
+ {expanded.has(group.key) && group.items.map(task => ( + + ))} +
+ ))} +
+
+ ); +} + +// ─── Task Detail ─── + +interface TaskDetailProps { + task: Task | null; + onEdit: () => void; + onDelete: () => void; + onStatusChange: (status: TaskStatus) => void; +} + +function TaskDetail({ task, onEdit, onDelete, onStatusChange }: TaskDetailProps) { + const { t } = useTranslation(); + if (!task) { + return ; + } + const overdue = isOverdue(task.due_date, task.status); + return ( +
+
+

{task.title}

+
+ + +
+
+
+ + {t(`tasks.status${task.status.charAt(0).toUpperCase() + task.status.slice(1).replace('_', '')}`)} + + + {t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)} + + {overdue && Überfällig} +
+ {task.description &&

{task.description}

} +
+
{t('tasks.dueDate')}: {formatDate(task.due_date)}
+
Status: + +
+
+ {task.status !== 'done' && ( + + )} +
+ ); +} + +// ─── Main Page ─── + export function TasksPage() { const { t } = useTranslation(); const toast = useToast(); + const { registerItems, unregisterPlugin } = usePluginToolbarStore(); const [page, setPage] = useState(1); const [filter, setFilter] = useState({}); @@ -66,26 +229,31 @@ export function TasksPage() { const [createModalOpen, setCreateModalOpen] = useState(false); const [editingTask, setEditingTask] = useState(null); const [selectedTask, setSelectedTask] = useState(null); + const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list'); + const [activeView, setActiveView] = useState<'tree' | 'list' | 'detail'>('tree'); - // Debounce search React.useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 300); return () => clearTimeout(timer); }, [search]); - const effectiveFilter = useMemo( - () => ({ ...filter, search: debouncedSearch || undefined }), - [filter, debouncedSearch], - ); - - const { data, isLoading } = useTasks(page, 25, effectiveFilter); + const effectiveFilter = useMemo(() => ({ ...filter, search: debouncedSearch || undefined }), [filter, debouncedSearch]); + const { data, isLoading } = useTasks(page, 100, effectiveFilter); const createMutation = useCreateTask(); const updateMutation = useUpdateTask(); const deleteMutation = useDeleteTask(); const statusMutation = useUpdateTaskStatus(); - const tasks = data?.items || []; + // Register toolbar + useEffect(() => { + registerItems('tasks', [ + { id: 'new-task', plugin: 'tasks', label: t('tasks.create'), group: 'actions', onClick: () => { setEditingTask(null); setCreateModalOpen(true); }, icon: }, + { id: 'view-mode', plugin: 'tasks', label: viewMode === 'list' ? 'Liste' : 'Kanban', group: 'view-mode', type: 'select' as const, selectValue: viewMode, selectOptions: [{ value: 'list', label: 'Liste' }, { value: 'kanban', label: 'Kanban' }], onSelect: (v: string) => setViewMode(v as 'list' | 'kanban'), onClick: () => {} }, + ]); + return () => unregisterPlugin('tasks'); + }, [registerItems, unregisterPlugin, t, viewMode]); + const handleCreate = async (formData: Partial) => { try { await createMutation.mutateAsync({ @@ -94,13 +262,10 @@ export function TasksPage() { priority: formData.priority || 'medium', status: formData.status || 'open', due_date: formData.due_date || null, - contact_id: formData.contact_id || null, }); toast.success(t('tasks.created')); setCreateModalOpen(false); - } catch (err: unknown) { const errObj = asError(err); - toast.error(errObj.message || t('common.error')); - } + } catch (err: unknown) { const errObj = asError(err); toast.error(errObj.message || t('common.error')); } }; const handleUpdate = async (formData: Partial) => { @@ -109,9 +274,7 @@ export function TasksPage() { await updateMutation.mutateAsync({ id: editingTask.id, data: formData }); toast.success(t('tasks.updated')); setEditingTask(null); - } catch (err: unknown) { const errObj = asError(err); - toast.error(errObj.message || t('common.error')); - } + } catch (err: unknown) { const errObj = asError(err); toast.error(errObj.message || t('common.error')); } }; const handleDelete = async (task: Task) => { @@ -120,232 +283,160 @@ export function TasksPage() { await deleteMutation.mutateAsync(task.id); toast.success(t('tasks.deleted')); setSelectedTask(null); - } catch (err: unknown) { const errObj = asError(err); - toast.error(errObj.message || t('common.error')); - } + } catch (err: unknown) { const errObj = asError(err); toast.error(errObj.message || t('common.error')); } }; const handleStatusChange = async (task: Task, newStatus: TaskStatus) => { try { await statusMutation.mutateAsync({ id: task.id, status: newStatus }); toast.success(t('tasks.statusUpdated')); - } catch (err: unknown) { const errObj = asError(err); - toast.error(errObj.message || t('common.error')); - } + if (selectedTask?.id === task.id) setSelectedTask({ ...task, status: newStatus }); + } catch (err: unknown) { const errObj = asError(err); toast.error(errObj.message || t('common.error')); } }; + const handleSelectTask = useCallback((task: Task) => { + setSelectedTask(task); + setActiveView('detail'); + }, []); + + // Kanban view + const kanbanColumns = STATUS_GROUPS.map(g => ({ + ...g, + items: tasks.filter(t => g.statuses.includes(t.status)), + })); + return ( -
- {/* Header */} -
-

- - {t('tasks.title')} -

- -
+
+ {/* Desktop: 3-column layout */} +
+ + + - {/* Filters */} -
- setSearch(e.target.value)} - className="w-48" - /> - { setFilter(f => ({ ...f, priority: e.target.value || undefined })); setPage(1); }} - className="w-40" - /> -
+
+ {/* List/Kanban */} +
+ {/* Search bar */} +
+ setSearch(e.target.value)} className="w-full" /> +
- {/* List */} -
- {isLoading ? ( -
- -
- ) : tasks.length === 0 ? ( - - ) : ( -
    - {tasks.map((task) => { - const overdue = isOverdue(task.due_date, task.status); - return ( -
  • setSelectedTask(task)} - data-testid={`task-item-${task.id}`} - > -
    -
    -
    - {task.title} - {overdue && ( - + {isLoading ? ( +
    + ) : tasks.length === 0 ? ( + + ) : viewMode === 'list' ? ( +
      + {tasks.map((task) => { + const overdue = isOverdue(task.due_date, task.status); + return ( +
    • handleSelectTask(task)} data-testid={`task-item-${task.id}`}> +
      +
      +
      + {task.title} + {overdue && } +
      + {task.description &&

      {task.description}

      } +
      + {t(`tasks.status${task.status.charAt(0).toUpperCase() + task.status.slice(1).replace('_', '')}`)} + {t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)} + {task.due_date && {formatDate(task.due_date)}} +
      +
      + {task.status !== 'done' && ( + )}
      - {task.description && ( -

      {task.description}

      - )} -
      - - {t(`tasks.status${task.status.charAt(0).toUpperCase() + task.status.slice(1).replace('_', '')}`)} - - - {t(`tasks.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`)} - - {task.due_date && ( - - - {formatDate(task.due_date)} - - )} -
      -
    -
    - {task.status !== 'done' && ( - - )} - - +
  • + ); + })} +
+ ) : ( + /* Kanban view */ +
+ {kanbanColumns.map(col => ( +
+

+ {col.label}{col.items.length} +

+
+ {col.items.map(task => ( +
handleSelectTask(task)} className={`bg-white rounded-md p-3 shadow-sm cursor-pointer hover:shadow-md transition-shadow ${selectedTask?.id === task.id ? 'ring-2 ring-primary-500' : ''}`}> +

{task.title}

+
+ {task.priority} + {task.due_date && {formatDate(task.due_date)}} +
+
+ ))} + {col.items.length === 0 &&

Keine Tasks

}
- - ); - })} - - )} + ))} +
+ )} +
- {/* Pagination */} - {data && data.total > 25 && ( -
- - {((page - 1) * 25) + 1}–{Math.min(page * 25, data.total)} / {data.total} - -
- - + {/* Detail panel */} + + { setEditingTask(selectedTask); }} + onDelete={() => { if (selectedTask) handleDelete(selectedTask); }} + onStatusChange={(s) => { if (selectedTask) handleStatusChange(selectedTask, s); }} + /> + +
+
+ + {/* Mobile: single pane */} +
+ {activeView === 'tree' ? ( + + ) : activeView === 'detail' && selectedTask ? ( + { setEditingTask(selectedTask); }} onDelete={() => handleDelete(selectedTask)} onStatusChange={(s) => handleStatusChange(selectedTask, s)} /> + ) : ( +
+
+ setSearch(e.target.value)} className="w-full" />
+ {isLoading ? : tasks.length === 0 ? : ( +
    + {tasks.map(task => ( +
  • handleSelectTask(task)}> + {task.title} +
    {task.status}
    +
  • + ))} +
+ )}
)}
{/* Create/Edit Modal */} - { setCreateModalOpen(false); setEditingTask(null); }} - task={editingTask} - onSubmit={editingTask ? handleUpdate : handleCreate} - /> - - {/* Detail Modal */} - {selectedTask && ( - setSelectedTask(null)} title={selectedTask.title} size="lg"> -
-
- - {t(`tasks.status${selectedTask.status.charAt(0).toUpperCase() + selectedTask.status.slice(1).replace('_', '')}`)} - - - {t(`tasks.priority${selectedTask.priority.charAt(0).toUpperCase() + selectedTask.priority.slice(1)}`)} - -
- {selectedTask.description && ( -

{selectedTask.description}

- )} -
- {t('tasks.dueDate')}: {formatDate(selectedTask.due_date)} -
-
- {selectedTask.status !== 'done' && ( - - )} - - -
-
-
- )} + { setCreateModalOpen(false); setEditingTask(null); }} task={editingTask} onSubmit={editingTask ? handleUpdate : handleCreate} />
); } // ── Task Create/Edit Modal ── -function TaskModal({ - open, - onClose, - task, - onSubmit, -}: { - open: boolean; - onClose: () => void; - task?: Task | null; - onSubmit: (data: Partial) => void; +function TaskModal({ open, onClose, task, onSubmit }: { + open: boolean; onClose: () => void; task?: Task | null; onSubmit: (data: Partial) => void; }) { const { t } = useTranslation(); const [title, setTitle] = useState(task?.title || ''); const [description, setDescription] = useState(task?.description || ''); - const [priority, setPriority] = useState(task?.priority || 'medium'); - const [status, setStatus] = useState(task?.status || 'open'); + const [priority, setPriority] = useState(task?.priority || 'medium'); + const [status, setStatus] = useState(task?.status || 'open'); const [dueDate, setDueDate] = useState(task?.due_date ? task.due_date.slice(0, 10) : ''); React.useEffect(() => { if (open) { - setTitle(task?.title || ''); - setDescription(task?.description || ''); - setPriority(task?.priority || 'medium'); - setStatus(task?.status || 'open'); + setTitle(task?.title || ''); setDescription(task?.description || ''); + setPriority(task?.priority || 'medium'); setStatus(task?.status || 'open'); setDueDate(task?.due_date ? task.due_date.slice(0, 10) : ''); } }, [open, task]); @@ -353,66 +444,20 @@ function TaskModal({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!title.trim()) return; - onSubmit({ - title: title.trim(), - description: description || null, - priority: priority as any, - status: status as any, - due_date: dueDate ? new Date(dueDate).toISOString() : null, - }); + onSubmit({ title: title.trim(), description: description || null, priority: priority as any, status: status as any, due_date: dueDate ? new Date(dueDate).toISOString() : null }); }; return (
- setTitle(e.target.value)} - required - data-testid="task-title-input" - /> -
- -