/** * Tasks page — 3-column explorer layout (tree + list + detail). * Rebuilt for UI-Overhaul Phase 4. */ import { asError } from '@/utils/errorTypes'; 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'; 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 { usePluginToolbarStore } from '@/store/pluginToolbarStore'; import { Loader2, Plus, CheckSquare, Clock, AlertCircle, ChevronDown, ChevronRight, List, LayoutGrid } from 'lucide-react'; import { useTasks, useCreateTask, useUpdateTask, useDeleteTask, useUpdateTaskStatus, type Task, type TaskFilter, type TaskStatus, } from '@/api/tasks'; const STATUS_COLORS: Record = { open: 'secondary', in_progress: 'info', review: 'warning', blocked: 'danger', done: 'success', cancelled: 'secondary', }; const PRIORITY_COLORS: Record = { low: 'secondary', medium: 'info', high: 'warning', urgent: 'danger', }; const STATUS_GROUPS: { key: string; label: string; statuses: TaskStatus[] }[] = [ { key: 'open', label: 'Offen', statuses: ['open'] }, { key: 'in_progress', label: 'In Bearbeitung', statuses: ['in_progress', 'review'] }, { key: 'blocked', label: 'Blockiert', statuses: ['blocked'] }, { key: 'done', label: 'Erledigt', statuses: ['done', 'cancelled'] }, ]; const PRIORITY_GROUPS: { key: string; label: string }[] = [ { key: 'urgent', label: 'Dringend' }, { key: 'high', label: 'Hoch' }, { key: 'medium', label: 'Mittel' }, { key: 'low', label: 'Niedrig' }, ]; function formatDate(dateStr: string | null): string { if (!dateStr) return '—'; try { return new Date(dateStr).toLocaleDateString(); } catch { return dateStr; } } function isOverdue(dateStr: string | null, status: string): boolean { if (!dateStr || status === 'done' || status === 'cancelled') return false; try { return new Date(dateStr) < new Date(); } catch { return false; } } // ─── Task Tree ─── interface TaskTreeProps { tasks: Task[]; selectedTaskId: string | null; onSelect: (task: Task) => 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 = usePluginToolbarStore((s) => s.registerItems); const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin); const [page, setPage] = useState(1); const [filter, setFilter] = useState({}); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); 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'); 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, 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({ title: formData.title || '', description: formData.description || null, priority: formData.priority || 'medium', status: formData.status || 'open', due_date: formData.due_date || null, }); toast.success(t('tasks.created')); setCreateModalOpen(false); } catch (err: unknown) { const errObj = asError(err); toast.error(errObj.message || t('common.error')); } }; const handleUpdate = async (formData: Partial) => { if (!editingTask) return; try { 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')); } }; const handleDelete = async (task: Task) => { if (!window.confirm(t('tasks.deleteConfirm'))) return; try { 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')); } }; const handleStatusChange = async (task: Task, newStatus: TaskStatus) => { try { await statusMutation.mutateAsync({ id: task.id, status: newStatus }); toast.success(t('tasks.statusUpdated')); 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 (
{/* Desktop: 3-column layout */}
{/* List/Kanban */}
{/* Search bar */}
setSearch(e.target.value)} className="w-full" />
{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' && ( )}
  • ); })}
) : ( /* 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 &&

{t('tasks.keinetasks')}

}
))}
)}
{/* 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} />
); } // ── Task Create/Edit Modal ── 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 [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'); setDueDate(task?.due_date ? task.due_date.slice(0, 10) : ''); } }, [open, task]); 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 }); }; return (
setTitle(e.target.value)} required data-testid="task-title-input" />