Files
MindSift/frontend/src/features/triage/TasksView.tsx
T
sandrodachandClaude Sonnet 5.5 4e9c4ffa8c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s
feat(tasks): add read-only sharing permission for task lists
Task list shares now carry a permission (editor/viewer). The server rejects
writes from viewers with 403, and the UI hides or disables edit controls
for read-only lists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:30:28 +01:00

2482 lines
100 KiB
TypeScript

import React, { useEffect, useState, useRef, useMemo } from 'react';
import {
Check,
Plus,
Calendar,
Trash2,
RotateCcw,
FileText,
LayoutList,
Columns,
ArrowRight,
ArrowLeft,
Edit3,
X,
Layers,
Users,
CheckSquare,
User as UserIcon,
Lock,
Hash,
AlertTriangle,
Flame,
Settings,
ChevronDown,
Folder,
Filter,
Webhook,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete';
import { useMobileActions } from '../../context/MobileActionContext';
const getInitials = (name?: string): string => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
};
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number;
initialSelectedTaskId?: string | null;
}
export const TasksView: React.FC<TasksViewProps> = ({
currentUser,
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
initialSelectedTaskId,
}) => {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
const [tasks, setTasks] = useState<Thought[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [selectedListId, setSelectedListId] = useState<string>('all');
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [discarded, setDiscarded] = useState<Thought[]>([]);
const [loading, setLoading] = useState(true);
// Drag and Drop state
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
// Quick Task Add state
const [quickTitle, setQuickTitle] = useState('');
const [quickDueDate, setQuickDueDate] = useState('');
const quickInputRef = useRef<HTMLInputElement | null>(null);
const { setGroups, setPrimaryAction } = useMobileActions();
// Mobile task creation & filter modal state
const [showMobileCreateModal, setShowMobileCreateModal] = useState(false);
const [mobileCreateTitle, setMobileCreateTitle] = useState('');
const [mobileCreateDueDate, setMobileCreateDueDate] = useState('');
const [mobileCreatePriority, setMobileCreatePriority] = useState<PriorityLevel>('normal');
const [mobileCreateListId, setMobileCreateListId] = useState<string>('');
const [showFilterModal, setShowFilterModal] = useState(false);
// New TaskList modal state
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('📋');
// Share TaskList modal state
const [showShareListModal, setShowShareListModal] = useState(false);
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [shareLoading, setShareLoading] = useState(false);
// Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [extraTask, setExtraTask] = useState<Thought | null>(null);
useEffect(() => {
if (initialSelectedTaskId) {
setDetailTaskId(initialSelectedTaskId);
setShowDetailModal(true);
setFilter('open');
api.getThought(initialSelectedTaskId).then((th) => {
if (th) setExtraTask(th);
}).catch(() => {});
}
}, [initialSelectedTaskId]);
// Manage columns modal state
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
const [globalColumns, setGlobalColumns] = useState<KanbanColumn[]>(() => {
try {
const stored = localStorage.getItem('mindsift:kanban_columns');
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch {}
return DEFAULT_KANBAN_COLUMNS;
});
// Hashtag filtering, grouping and sorting state
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
const loadData = async () => {
setLoading(true);
try {
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getUserTeams().catch(() => []),
api.getCollaborators().catch(() => []),
api.getThoughts('discarded').catch(() => []),
]);
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
setTeams(Array.isArray(allTeams) ? allTeams : []);
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, [refreshTrigger, selectedListId]);
const selectedListIdRef = useRef(selectedListId);
selectedListIdRef.current = selectedListId;
const onRefreshStatsRef = useRef(onRefreshStats);
onRefreshStatsRef.current = onRefreshStats;
// Live-Sync WebSocket listener for tasks (registered once on mount)
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg || !msg.event) return;
if (msg.event === 'TASK_CREATED') {
const created = msg.payload as Thought;
if (
created &&
(created.status === 'converted' ||
created.kanbanStatus ||
created.taskListId ||
created.isCompleted !== undefined)
) {
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
return [created, ...prev];
});
onRefreshStatsRef.current();
}
} else if (msg.event === 'TASK_UPDATED') {
const updated = msg.payload as Thought;
if (updated && updated.id) {
setTasks((prev) => {
const exists = prev.some((t) => t.id === updated.id);
if (!exists) {
if (
(updated.status === 'converted' ||
updated.kanbanStatus ||
updated.taskListId ||
updated.isCompleted !== undefined) &&
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
) {
return [updated, ...prev];
}
return prev;
}
if (selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current) {
return prev.filter((t) => t.id !== updated.id);
}
return prev.map((t) => (t.id === updated.id ? updated : t));
});
onRefreshStatsRef.current();
}
} else if (msg.event === 'TASK_DELETED') {
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
if (deletedId) {
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
onRefreshStatsRef.current();
}
} else if (msg.event === 'TASK_REORDERED') {
loadData();
}
};
window.addEventListener('mindsift:ws', handleWs);
return () => window.removeEventListener('mindsift:ws', handleWs);
}, []);
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
const isSelectedListReadOnly = currentSelectedList?.permission === 'viewer';
const isTaskReadOnly = (task: Thought) =>
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
// Active Kanban Columns (from selected list, or global custom, or default)
const activeColumns: KanbanColumn[] = useMemo(() => {
if (
currentSelectedList &&
Array.isArray(currentSelectedList.columns) &&
currentSelectedList.columns.length > 0
) {
return currentSelectedList.columns;
}
return globalColumns;
}, [currentSelectedList, globalColumns]);
const safeTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
const seen = new Set<string>();
const unique: Thought[] = [];
for (const t of tasks) {
if (t && t.id && !seen.has(t.id)) {
seen.add(t.id);
unique.push(t);
}
}
return unique;
}, [tasks]);
const detailTask = useMemo(() => {
if (!detailTaskId) return null;
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
}, [detailTaskId, tasks, extraTask]);
const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
// Available unique hashtags from current tasks
const allAvailableTags = useMemo(() => {
const set = new Set<string>();
for (const t of safeTasks) {
if (Array.isArray(t.tags)) {
for (const tg of t.tags) {
if (tg) set.add(tg);
}
}
}
return Array.from(set).sort();
}, [safeTasks]);
// Filtered tasks for List view
const openTasks = safeTasks.filter((t) => !t.isCompleted);
const doneTasks = safeTasks.filter((t) => t.isCompleted);
const filteredTasks = useMemo(() => {
let result = safeTasks;
if (filter === 'open') {
result = result.filter((t) => !t.isCompleted);
} else if (filter === 'done') {
result = result.filter((t) => t.isCompleted);
}
if (selectedTag) {
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
}
if (sortBy === 'custom') {
return [...result].sort((a, b) => {
const orderA = a.sortOrder ?? 0;
const orderB = b.sortOrder ?? 0;
if (orderA !== orderB) return orderA - orderB;
return b.createdAt.localeCompare(a.createdAt);
});
}
return [...result].sort((a, b) => {
if (sortBy === 'due') {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return a.dueDate.localeCompare(b.dueDate);
}
if (sortBy === 'priority') {
const pScore: Record<string, number> = { urgent: 3, high: 2, normal: 1, low: 0 };
const scoreA = pScore[a.priority] || 1;
const scoreB = pScore[b.priority] || 1;
return scoreB - scoreA;
}
if (sortBy === 'alpha') {
return a.content.localeCompare(b.content);
}
return b.createdAt.localeCompare(a.createdAt);
});
}, [safeTasks, filter, selectedTag, sortBy]);
// Tasks for Kanban board (shows all non-discarded matching tag)
const kanbanTasks = useMemo(() => {
let result = safeTasks;
if (selectedTag) {
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
}
return result;
}, [safeTasks, selectedTag]);
// Grouped tasks calculation
const groupedTasks = useMemo(() => {
if (groupBy === 'none') return null;
const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = [];
if (groupBy === 'tag') {
const map = new Map<string, Thought[]>();
const withoutTag: Thought[] = [];
for (const t of filteredTasks) {
if (!t.tags || t.tags.length === 0) {
withoutTag.push(t);
} else {
for (const tg of t.tags) {
if (!map.has(tg)) map.set(tg, []);
map.get(tg)!.push(t);
}
}
}
Array.from(map.keys()).sort().forEach((tg) => {
const list = map.get(tg)!;
groups.push({ key: `tag:${tg}`, title: `#${tg}`, count: list.length, tasks: list });
});
if (withoutTag.length > 0) {
groups.push({ key: 'tag:none', title: 'Ohne Hashtag', count: withoutTag.length, tasks: withoutTag });
}
} else if (groupBy === 'status') {
activeColumns.forEach((col) => {
const list = filteredTasks.filter((t) => {
if (col.isDone) return t.isCompleted || t.kanbanStatus === col.id;
return !t.isCompleted && (t.kanbanStatus === col.id || (!t.kanbanStatus && col.id === 'todo'));
});
groups.push({ key: `status:${col.id}`, title: col.name, count: list.length, tasks: list });
});
} else if (groupBy === 'priority') {
const urgent = filteredTasks.filter((t) => t.priority === 'urgent');
const high = filteredTasks.filter((t) => t.priority === 'high');
const normal = filteredTasks.filter((t) => t.priority !== 'urgent' && t.priority !== 'high');
if (urgent.length > 0) groups.push({ key: 'p:urgent', title: '🔥 Dringend', count: urgent.length, tasks: urgent });
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
} else if (groupBy === 'due') {
const today = new Date().toISOString().split('T')[0];
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
const noDue = filteredTasks.filter((t) => !t.dueDate);
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
if (upcoming.length > 0) groups.push({ key: 'due:upcoming', title: 'Demnächst', count: upcoming.length, tasks: upcoming });
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
}
return groups;
}, [filteredTasks, groupBy, activeColumns]);
// --- TASK ACTIONS ---
const handleToggleDone = async (task: Thought) => {
triggerHaptic(15);
const nextState = !task.isCompleted;
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
setTasks((prev) =>
prev.map((t) =>
t.id === task.id ? { ...t, isCompleted: nextState, kanbanStatus: nextKanban } : t
)
);
await api.updateThought(task.id, { isCompleted: nextState, kanbanStatus: nextKanban });
onRefreshStats();
};
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
triggerHaptic(15);
const targetCol = activeColumns.find((c) => c.id === nextStatus);
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, kanbanStatus: nextStatus, isCompleted } : t))
);
await api.updateThought(task.id, { kanbanStatus: nextStatus, isCompleted });
onRefreshStats();
};
const handleSetPriority = async (task: Thought, nextPriority: string) => {
triggerHaptic(10);
const normalized = nextPriority === 'none' || nextPriority === '' ? 'normal' : nextPriority;
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, priority: normalized as any } : t))
);
await api.updateThought(task.id, { priority: normalized });
onRefreshStats();
};
const handleCyclePriority = async (task: Thought) => {
triggerHaptic(10);
const nextPriority =
task.priority === 'urgent'
? 'normal'
: task.priority === 'high'
? 'urgent'
: 'high';
await handleSetPriority(task, nextPriority);
};
const handleUpdateTaskTitle = async (task: Thought, newTitle: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, content: newTitle } : t)));
await api.updateThought(task.id, { content: newTitle });
onRefreshStats();
};
const handleUpdateTaskDueDate = async (task: Thought, newDate: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, dueDate: newDate } : t)));
await api.updateThought(task.id, { dueDate: newDate });
};
const handleUpdateTaskTaskList = async (task: Thought, newListId: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, taskListId: newListId } : t)));
await api.updateThought(task.id, { taskListId: newListId });
if (selectedListId !== 'all' && selectedListId !== newListId) {
setTasks((prev) => prev.filter((t) => t.id !== task.id));
}
onRefreshStats();
};
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
await api.updateThought(task.id, { notes: newNotes });
};
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
await api.updateThought(task.id, { tags: newTags });
onRefreshStats();
};
const handleToggleSubtask = async (task: Thought, subtaskId: string) => {
triggerHaptic(10);
const updatedSubtasks = (task.subtasks || []).map((s) =>
s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s
);
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleAddSubtask = async (task: Thought, title: string) => {
triggerHaptic(10);
const newSub: SubTask = {
id: `st_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
title: title.trim(),
isCompleted: false,
createdAt: new Date().toISOString(),
};
const updatedSubtasks = [...(task.subtasks || []), newSub];
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleDeleteSubtask = async (task: Thought, subtaskId: string) => {
triggerHaptic(10);
const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId);
setTasks((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
);
await api.updateThought(task.id, { subtasks: updatedSubtasks });
};
const handleConvertToDoc = async (task: Thought) => {
triggerHaptic(20);
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
onRefreshStats();
if (updated.convertedRefId) {
onOpenDocument(updated.convertedRefId);
} else {
loadData();
}
};
const handleRestore = async (item: Thought) => {
triggerHaptic(15);
await api.triageAction(item.id, 'restore');
await loadData();
onRefreshStats();
};
const handleDeletePermanent = async (id: string) => {
triggerHaptic(20);
await api.deleteThought(id);
await loadData();
onRefreshStats();
};
const handlePurgeAllDiscarded = async () => {
if (!window.confirm('Alle verworfenen Gedanken endgültig löschen?')) return;
triggerHaptic(20);
await api.purgeDiscarded();
await loadData();
onRefreshStats();
};
const handleQuickAddTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = quickTitle.trim();
if (!title) return;
triggerHaptic(10);
setQuickTitle('');
try {
const created = await api.captureThought(title, 'normal', 'manual', quickDueDate, {
target: 'task',
taskListId: selectedListId === 'all' ? '' : selectedListId,
});
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
return [created, ...prev];
});
onRefreshStats();
} catch {
loadData();
}
};
const handleMobileCreateTask = async (e: React.FormEvent) => {
e.preventDefault();
const title = mobileCreateTitle.trim();
if (!title) return;
triggerHaptic(12);
try {
const created = await api.captureThought(title, mobileCreatePriority, 'manual', mobileCreateDueDate || undefined, {
target: 'task',
taskListId: mobileCreateListId || (selectedListId === 'all' ? '' : selectedListId),
});
setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
return [created, ...prev];
});
setMobileCreateTitle('');
setMobileCreateDueDate('');
setMobileCreatePriority('normal');
setShowMobileCreateModal(false);
onRefreshStats();
} catch {
loadData();
}
};
const handleQuickAddTaskToColumn = async (columnId: string) => {
const title = prompt('Neue Aufgabe für diese Spalte:');
if (!title || !title.trim()) return;
triggerHaptic(10);
const targetCol = activeColumns.find((c) => c.id === columnId);
const isCompleted = targetCol ? !!targetCol.isDone : columnId === 'done';
try {
const created = await api.captureThought(title.trim(), 'normal', 'manual', undefined, {
target: 'task',
taskListId: selectedListId === 'all' ? '' : selectedListId,
});
if (created && created.id) {
const updated = await api.updateThought(created.id, { kanbanStatus: columnId, isCompleted });
setTasks((prev) => [updated, ...prev]);
onRefreshStats();
}
} catch {
loadData();
}
};
const handleTaskDragStart = (e: React.DragEvent, task: Thought) => {
e.stopPropagation();
if (isTaskReadOnly(task)) {
e.preventDefault();
return;
}
e.dataTransfer.setData('application/mindsift-task-id', task.id);
e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move';
setDraggingTaskId(task.id);
};
const handleTaskDragOver = (e: React.DragEvent, targetTaskId: string) => {
e.preventDefault();
e.stopPropagation();
if (!draggingTaskId || draggingTaskId === targetTaskId) return;
const rect = e.currentTarget.getBoundingClientRect();
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
if (
!dragOverTaskTarget ||
dragOverTaskTarget.taskId !== targetTaskId ||
dragOverTaskTarget.position !== position
) {
setDragOverTaskTarget({ taskId: targetTaskId, position });
}
};
const handleTaskDragLeave = (e: React.DragEvent, targetTaskId: string) => {
e.stopPropagation();
if (dragOverTaskTarget?.taskId === targetTaskId) {
setDragOverTaskTarget(null);
}
};
const handleDropOnTask = async (e: React.DragEvent, targetTask: Thought, newKanbanCol?: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-task-id') ||
e.dataTransfer.getData('text/plain') ||
draggingTaskId;
const position = dragOverTaskTarget?.position || 'after';
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
if (!sourceId || sourceId === targetTask.id) return;
triggerHaptic(15);
const sourceTask = tasks.find((t) => t.id === sourceId);
if (!sourceTask) return;
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
const targetIdx = remainingTasks.findIndex((t) => t.id === targetTask.id);
if (targetIdx === -1) return;
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
const updatedSourceTask: Thought = {
...sourceTask,
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
isCompleted: newKanbanCol ? newKanbanCol === 'done' : targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
};
const newTasks = [...remainingTasks];
newTasks.splice(insertIdx, 0, updatedSourceTask);
const updatedWithSort = newTasks.map((t, idx) => ({
...t,
sortOrder: idx,
}));
setTasks(updatedWithSort);
setSortBy('custom');
const reorderPayload = updatedWithSort.map((t) => ({
id: t.id,
sortOrder: t.sortOrder ?? 0,
kanbanStatus: t.kanbanStatus,
}));
try {
await api.reorderTasks(
reorderPayload,
selectedListId !== 'all' ? selectedListId : undefined
);
} catch {
loadData();
}
};
const handleDropOnKanbanCol = async (e: React.DragEvent, colId: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-task-id') ||
e.dataTransfer.getData('text/plain') ||
draggingTaskId;
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
if (!sourceId) return;
triggerHaptic(15);
const sourceTask = tasks.find((t) => t.id === sourceId);
if (!sourceTask) return;
const isDone = colId === 'done';
const updatedSourceTask: Thought = {
...sourceTask,
kanbanStatus: colId,
isCompleted: isDone,
};
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
const newTasks = [updatedSourceTask, ...remainingTasks];
const updatedWithSort = newTasks.map((t, idx) => ({
...t,
sortOrder: idx,
}));
setTasks(updatedWithSort);
setSortBy('custom');
const reorderPayload = updatedWithSort.map((t) => ({
id: t.id,
sortOrder: t.sortOrder ?? 0,
kanbanStatus: t.kanbanStatus,
}));
try {
await api.reorderTasks(
reorderPayload,
selectedListId !== 'all' ? selectedListId : undefined
);
} catch {
loadData();
}
};
const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault();
const title = newListTitle.trim();
if (!title) return;
try {
const created = await api.createTaskList({
title,
icon: newListIcon,
});
const freshLists = await api.getTaskLists();
setTaskLists(freshLists);
setSelectedListId(created.id);
setShowNewListModal(false);
setNewListTitle('');
setNewListIcon('📋');
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
}
};
const handleDeleteTaskList = async (listId: string) => {
if (
!window.confirm(
'Möchten Sie diese Aufgabenliste wirklich löschen? Die enthaltenen Aufgaben bleiben erhalten.'
)
)
return;
try {
await api.deleteTaskList(listId);
setTaskLists((prev) => prev.filter((l) => l.id !== listId));
setSelectedListId('all');
} catch (err: any) {
alert(err.message);
}
};
const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl);
setSelectedShareTargetId('');
setSelectedShareUser(null);
setShowShareListModal(true);
try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getTaskListShares(tl.id).catch(() => []),
]);
setTeams(freshTeams);
setCollaborators(freshUsers);
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
} catch {
// ignore
}
};
const handleAddShare = async () => {
if (!sharingTaskList) return;
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
if (!targetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: targetId,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
setSelectedShareTargetId('');
setSelectedShareUser(null);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
} finally {
setShareLoading(false);
}
};
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
triggerHaptic(15);
if (currentSelectedList && selectedListId !== 'all') {
const updated = await api.updateTaskList(currentSelectedList.id, { columns: newColumns });
setTaskLists((prev) => prev.map((l) => (l.id === updated.id ? updated : l)));
} else {
localStorage.setItem('mindsift:kanban_columns', JSON.stringify(newColumns));
setGlobalColumns(newColumns);
}
};
useEffect(() => {
setPrimaryAction(() => {
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true);
});
const primaryItems: any[] = [
{
id: 'new-task',
label: 'Neue Aufgabe erstellen',
icon: <Plus className="w-4 h-4" />,
variant: 'accent',
onClick: () => {
setMobileCreateListId(selectedListId === 'all' || isSelectedListReadOnly ? '' : selectedListId);
setShowMobileCreateModal(true);
},
},
];
const filterSortActiveCount =
(selectedTag ? 1 : 0) + (groupBy !== 'none' ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0);
const filterItems: any[] = [
{
id: 'tasks-filter-modal',
label: 'Filter, Hashtags & Sortierung',
icon: <Filter className="w-4 h-4 text-accent" />,
badge: filterSortActiveCount > 0 ? `${filterSortActiveCount} aktiv` : undefined,
active: filterSortActiveCount > 0,
onClick: () => setShowFilterModal(true),
},
];
const listManagementItems: any[] = [
{
id: 'new-task-list',
label: 'Neue Aufgabenliste anlegen',
icon: <Folder className="w-4 h-4" />,
onClick: () => setShowNewListModal(true),
},
];
if (currentSelectedList) {
listManagementItems.push({
id: 'share-task-list',
label: `Liste „${currentSelectedList.title}“ teilen`,
icon: <Users className="w-4 h-4" />,
onClick: () => handleOpenShareModal(currentSelectedList),
});
listManagementItems.push({
id: 'delete-task-list',
label: `Liste „${currentSelectedList.title}“ löschen`,
icon: <Trash2 className="w-4 h-4" />,
variant: 'danger',
onClick: () => handleDeleteTaskList(currentSelectedList.id),
});
}
const viewItems: any[] = [
{
id: 'filter-open',
label: 'Offene Aufgaben (Liste)',
icon: <LayoutList className="w-4 h-4" />,
badge: openTasks.length,
active: viewMode === 'list' && filter === 'open',
onClick: () => {
setViewMode('list');
setFilter('open');
},
},
{
id: 'filter-done',
label: 'Erledigte Aufgaben',
icon: <Check className="w-4 h-4" />,
badge: doneTasks.length,
active: viewMode === 'list' && filter === 'done',
onClick: () => {
setViewMode('list');
setFilter('done');
},
},
{
id: 'filter-discarded',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: safeDiscarded.length,
active: viewMode === 'list' && filter === 'discarded',
onClick: () => {
setViewMode('list');
setFilter('discarded');
},
},
{
id: 'view-kanban',
label: 'Kanban-Board Ansicht',
icon: <Columns className="w-4 h-4" />,
badge: kanbanTasks.length,
active: viewMode === 'kanban',
onClick: () => setViewMode('kanban'),
},
];
if (viewMode === 'kanban' && !isSelectedListReadOnly) {
viewItems.push({
id: 'manage-columns',
label: 'Kanban-Spalten verwalten',
icon: <Settings className="w-4 h-4" />,
onClick: () => setShowManageColumnsModal(true),
});
}
const listItems: any[] = [
{
id: 'list-all',
label: 'Alle Aufgaben',
icon: <Layers className="w-4 h-4" />,
active: selectedListId === 'all',
onClick: () => setSelectedListId('all'),
},
...safeTaskLists.map((tl) => ({
id: `list-${tl.id}`,
label: `${tl.icon || '📋'} ${tl.title}`,
badge: tl.tasksCount || undefined,
active: selectedListId === tl.id,
onClick: () => setSelectedListId(tl.id),
})),
];
setGroups([
{
id: 'tasks-primary',
items: primaryItems,
},
{
id: 'tasks-filters',
title: 'Filter & Sortierung',
items: filterItems,
},
{
id: 'tasks-list-management',
title: 'Listen-Verwaltung',
items: listManagementItems,
},
{
id: 'tasks-views',
title: 'Ansicht & Status',
items: viewItems,
},
{
id: 'tasks-lists',
title: 'Aufgabenlisten wechseln',
items: listItems,
},
]);
}, [
viewMode,
filter,
selectedListId,
selectedTag,
groupBy,
sortBy,
openTasks.length,
doneTasks.length,
safeDiscarded.length,
kanbanTasks.length,
safeTaskLists,
currentSelectedList,
isSelectedListReadOnly,
setPrimaryAction,
setGroups,
]);
const renderTaskProgressBar = (task: Thought) => {
if (!task.subtasks || task.subtasks.length === 0) return null;
const total = task.subtasks.length;
const done = task.subtasks.filter((s) => s.isCompleted).length;
const percent = Math.round((done / total) * 100);
return (
<div
className="space-y-0.5 mt-0.5 mb-0.5 select-none"
title={`${done} von ${total} Unteraufgaben erledigt (${percent}%)`}
>
<div className="flex items-center justify-between text-[9px] text-muted font-medium">
<span className="flex items-center gap-1">
<CheckSquare className="w-2.5 h-2.5 text-accent" />
<span>
{done}/{total} Unteraufgaben
</span>
</span>
<span className={percent === 100 ? 'text-status-ok font-bold' : 'text-muted'}>
{percent}%
</span>
</div>
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
percent === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
};
// Clean Task Row in List View
const renderTaskRow = (task: Thought) => {
const isCompleted = task.isCompleted;
const readOnly = isTaskReadOnly(task);
const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
return (
<div
key={task.id}
draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
}}
onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task)}
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer select-none ${
readOnly ? '' : 'cursor-grab active:cursor-grabbing'
} ${
isCompleted ? 'opacity-65' : ''
} ${isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''}`}
>
{/* Drop insertion line indicator */}
{isTarget && dragOverTaskTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverTaskTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-center gap-3 sm:gap-2.5 min-w-0 flex-1">
{/* Checkbox (larger touch target on mobile) */}
<button
type="button"
draggable={false}
disabled={readOnly}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleToggleDone(task);
}}
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={readOnly ? 'Nur Lesen' : isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
>
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button>
{/* Clickable Title & Metadata */}
<div
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className="min-w-0 flex-1 cursor-pointer space-y-1"
>
<div className="flex items-center gap-2 flex-wrap">
<p
className={`text-sm break-words leading-tight ${
isCompleted ? 'line-through text-faint' : 'text-main font-medium'
}`}
>
{task.content}
</p>
</div>
{/* Subtasks Progress Bar */}
{renderTaskProgressBar(task)}
{/* Interactive Badges Bar */}
<div
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
onClick={(e) => e.stopPropagation()}
>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-5 px-1.5 text-[10px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */}
<select
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
disabled={readOnly}
onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
className="hidden sm:inline-block h-5 px-1.5 text-[10px] rounded-btn border border-border bg-panel text-muted hover:text-main cursor-pointer"
title="Status / Zustand ändern"
>
{activeColumns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* Priority badge: click to cycle or clear */}
{task.priority === 'urgent' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="badge-accent text-[10px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
>
🔥 Dringend
</button>
)}
{task.priority === 'high' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.5 border border-status-warn text-status-warn rounded-btn text-[10px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
>
⚠️ Wichtig
</button>
)}
{task.source?.toLowerCase() === 'webhook' && (
<span
className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0"
title="Erstellt über Webhook"
>
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
{/* Note indicator */}
{task.notes && (
<button
type="button"
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className="inline-flex items-center gap-1 text-[10px] text-muted hover:text-main bg-canvas px-1.5 py-0.5 rounded border border-border"
title="Notiz anzeigen / bearbeiten"
>
<FileText className="w-2.5 h-2.5 text-accent" />
<span className="truncate max-w-[120px]">{task.notes}</span>
</button>
)}
{/* Due Date */}
{task.dueDate && (
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
<Calendar className="w-2.5 h-2.5 text-accent" />
<span>{task.dueDate}</span>
</span>
)}
{/* Hashtag badges */}
{task.tags?.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`text-[10px] px-1.5 py-0.2 rounded-pill border transition-colors ${
selectedTag === tag
? 'bg-accent/15 border-accent text-accent font-semibold'
: 'badge-neutral hover:border-accent'
}`}
title={`Nach #${tag} filtern`}
>
#{tag}
</button>
))}
</div>
</div>
</div>
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
<button
type="button"
draggable={false}
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className="text-faint hover:text-accent p-1.5 transition-colors"
title={readOnly ? 'Aufgabe ansehen' : 'Aufgabe bearbeiten'}
>
{readOnly ? <Lock className="w-3.5 h-3.5" /> : <Edit3 className="w-3.5 h-3.5" />}
</button>
{!readOnly && (
<>
<button
type="button"
draggable={false}
onClick={() => handleConvertToDoc(task)}
title="Als Notiz vertiefen"
className="text-faint hover:text-accent p-1.5 transition-colors hidden sm:block"
>
<FileText className="w-3.5 h-3.5" />
</button>
<button
type="button"
draggable={false}
onClick={() => handleDeletePermanent(task.id)}
title="Aufgabe löschen"
className="text-faint hover:text-status-err p-1.5 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</div>
);
};
return (
<div className="w-full space-y-3">
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* Task Lists selector tabs */}
<div className="flex flex-wrap items-center gap-1">
<button
onClick={() => setSelectedListId('all')}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
selectedListId === 'all'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
Alle Aufgaben
</button>
{safeTaskLists.map((tl) => (
<div key={tl.id} className="relative group flex items-center">
<button
onClick={() => setSelectedListId(tl.id)}
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
selectedListId === tl.id
? 'bg-panel border-accent text-main rounded-l-btn rounded-r-none border-r-0'
: 'bg-canvas border-border text-muted hover:text-main rounded-btn'
}`}
>
<span>{tl.icon}</span>
<span>{tl.title}</span>
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
)}
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
if (!isShared) return null;
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
if (otherMembers.length > 0) {
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
{m.avatarUrl ? (
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
) : (
getInitials(m.displayName || m.email)[0]
)}
</span>
))}
</div>
);
}
return (
<span title="Geteilt mit Team oder Benutzern">
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
</span>
);
})()}
</button>
{selectedListId === tl.id && (
<button
onClick={() => handleDeleteTaskList(tl.id)}
title="Liste löschen"
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
<button
onClick={() => setShowNewListModal(true)}
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Aufgabenliste anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
<div className="flex items-center gap-2">
{currentSelectedList && (() => {
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
? currentSelectedList.shares.length
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
</button>
) : (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon"
title="Aufgabenliste teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{/* View Switcher: List vs Kanban */}
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
<button
onClick={() => setViewMode('list')}
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors border-r border-border ${
viewMode === 'list' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
}`}
title="Listenansicht"
>
<LayoutList className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Liste</span>
</button>
<button
onClick={() => setViewMode('kanban')}
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors ${
viewMode === 'kanban'
? 'bg-panel text-main font-medium'
: 'text-muted hover:text-main'
}`}
title="Kanban-Board"
>
<Columns className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Kanban</span>
</button>
</div>
</div>
</div>
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
</span>
<span className="badge-neutral text-[11px] flex-shrink-0">
{viewMode === 'kanban'
? 'Kanban'
: filter === 'open'
? `Offen (${openTasks.length})`
: filter === 'done'
? `Erledigt (${doneTasks.length})`
: `Papierkorb (${safeDiscarded.length})`}
</span>
</div>
{currentSelectedList && (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
title="Aufgabenliste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
</div>
{isSelectedListReadOnly && (
<div className="flex items-center gap-2 p-2.5 rounded-btn bg-panel-elevated border border-border text-xs text-muted">
<Lock className="w-3.5 h-3.5 text-faint shrink-0" />
<span>
Diese Liste wurde von {currentSelectedList?.ownerName || 'einem anderen Benutzer'} nur zum Lesen freigegeben.
</span>
</div>
)}
{/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */}
{!isSelectedListReadOnly && (
<form
onSubmit={handleQuickAddTask}
className="hidden sm:flex items-center gap-1.5 panel-surface p-2 sm:p-1.5"
>
<Plus className="w-4 h-4 text-accent ml-1 flex-shrink-0" />
<input
ref={quickInputRef}
type="text"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
placeholder={
currentSelectedList
? `Aufgabe zu "${currentSelectedList.title}" hinzufügen…`
: 'Neue Aufgabe anlegen…'
}
className="flex-1 bg-transparent px-2 py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none min-w-0"
/>
<div className="flex items-center gap-1 flex-shrink-0">
<input
type="date"
value={quickDueDate}
onChange={(e) => setQuickDueDate(e.target.value)}
className="hidden sm:block h-7 px-2 input-field text-xs"
title="Fälligkeitsdatum"
/>
<button
type="submit"
disabled={!quickTitle.trim()}
className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40"
>
Hinzufügen
</button>
</div>
</form>
)}
{/* Mobile Active Filter Badge Banner (minimal and clean) */}
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && (
<div className="flex sm:hidden items-center justify-between gap-2 p-2 rounded-btn bg-accent/10 border border-accent/20 text-xs">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" />
{selectedTag && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent text-on-accent text-[11px] font-semibold">
#{selectedTag}
<button
type="button"
onClick={() => setSelectedTag(null)}
className="hover:opacity-75 cursor-pointer ml-0.5"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{groupBy !== 'none' && (
<span className="text-muted text-[11px]">
Gruppe: <strong className="text-main">{groupBy}</strong>
</span>
)}
{sortBy !== 'custom' && (
<span className="text-muted text-[11px]">
Sortiert: <strong className="text-main">{sortBy}</strong>
</span>
)}
</div>
<button
type="button"
onClick={() => {
setSelectedTag(null);
setGroupBy('none');
setSortBy('custom');
}}
className="text-accent text-[11px] font-semibold flex-shrink-0 hover:underline cursor-pointer"
>
Zurücksetzen
</button>
</div>
)}
{/* Hashtag Filter & Grouping / Sorting Toolbar (Desktop only - on mobile accessible via Logo Menu Filter & Sort) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 pt-1 pb-0.5">
{/* Hashtag filter chips */}
<div className="flex items-center gap-1.5 overflow-x-auto max-w-full pb-1">
<span className="text-[11px] font-semibold text-muted flex items-center gap-1 flex-shrink-0">
<Hash className="w-3 h-3 text-accent" />
<span>Hashtags:</span>
</span>
<button
onClick={() => setSelectedTag(null)}
className={`h-6 px-2 text-[11px] rounded-pill border transition-colors flex-shrink-0 ${
selectedTag === null
? 'bg-panel border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
Alle ({safeTasks.length})
</button>
{allAvailableTags.map((tag) => {
const count = safeTasks.filter((t) => t.tags?.includes(tag)).length;
const isSelected = selectedTag === tag;
return (
<button
key={tag}
onClick={() => setSelectedTag(isSelected ? null : tag)}
className={`h-6 px-2 text-[11px] rounded-pill border transition-colors flex items-center gap-1 flex-shrink-0 ${
isSelected
? 'bg-accent/15 border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>#{tag}</span>
<span className="text-[9px] opacity-70 font-mono">({count})</span>
</button>
);
})}
</div>
{/* Group By & Sort By Dropdowns & Manage Columns button */}
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
{viewMode === 'kanban' ? (
!isSelectedListReadOnly && (
<button
onClick={() => setShowManageColumnsModal(true)}
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
>
<Columns className="w-3.5 h-3.5 text-accent" />
<span>Spalten anpassen</span>
</button>
)
) : (
<>
{/* Group By */}
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Gruppe:</span>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value as any)}
className="h-7 px-2 input-field text-xs bg-panel"
>
<option value="none">Keine</option>
<option value="tag">Nach Hashtag</option>
<option value="status">Nach Status</option>
<option value="priority">Nach Priorität</option>
<option value="due">Nach Fälligkeit</option>
</select>
</div>
{/* Sort By */}
<div className="flex items-center gap-1 text-xs text-muted">
<span className="text-[11px]">Sortierung:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="h-7 px-2 input-field text-xs bg-panel"
>
<option value="custom">Manuell (Drag & Drop)</option>
<option value="created">Erstelldatum</option>
<option value="due">Fälligkeitsdatum</option>
<option value="priority">Priorität</option>
<option value="alpha">Titel (A-Z)</option>
</select>
</div>
</>
)}
</div>
</div>
{/* MOBILE CREATE TASK BOTTOM SHEET MODAL */}
{showMobileCreateModal && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => setShowMobileCreateModal(false)}
>
<div
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<CheckSquare className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Neue Aufgabe anlegen</h3>
</div>
<button
type="button"
onClick={() => setShowMobileCreateModal(false)}
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleMobileCreateTask} className="space-y-3">
<div>
<label className="text-[11px] font-semibold text-muted block mb-1">Aufgabentext</label>
<input
type="text"
autoFocus
value={mobileCreateTitle}
onChange={(e) => setMobileCreateTitle(e.target.value)}
placeholder="Was ist zu tun?…"
className="w-full input-field px-3 py-2 text-sm"
/>
</div>
{/* List Selector (if lists exist) */}
{safeTaskLists.length > 0 && (
<div>
<label className="text-[11px] font-semibold text-muted block mb-1">Aufgabenliste</label>
<select
value={mobileCreateListId}
onChange={(e) => setMobileCreateListId(e.target.value)}
className="w-full input-field px-2.5 py-2 text-xs bg-panel"
>
<option value="">Standard (Alle Aufgaben)</option>
{safeTaskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon || '📋'} {tl.title}
</option>
))}
</select>
</div>
)}
{/* Priority - 4 Buttons */}
<div className="space-y-1.5 pt-1">
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
<div className="grid grid-cols-4 gap-1.5">
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
high: { label: 'Wichtig', icon: <AlertTriangle className="w-3 h-3 text-status-warn" />, activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' },
urgent: { label: 'Dringend', icon: <Flame className="w-3 h-3 text-status-err" />, activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' },
};
const active = mobileCreatePriority === p;
const item = config[p];
return (
<button
key={p}
type="button"
onClick={() => {
triggerHaptic(10);
setMobileCreatePriority(p);
}}
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
active
? item.activeClass
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{item.icon}
<span className="truncate">{item.label}</span>
</button>
);
})}
</div>
</div>
{/* Due Date */}
<div className="space-y-1 pt-1">
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fällig am</span>
</label>
<input
type="date"
value={mobileCreateDueDate}
onChange={(e) => setMobileCreateDueDate(e.target.value)}
className="w-full input-field px-3 py-2 text-xs"
/>
</div>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setShowMobileCreateModal(false)}
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!mobileCreateTitle.trim()}
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
>
Aufgabe erstellen
</button>
</div>
</form>
</div>
</div>
)}
{/* MOBILE FILTER & SORT MODAL */}
{showFilterModal && (
<div
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
onClick={() => setShowFilterModal(false)}
>
<div
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
<Filter className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Filter & Sortierung</h3>
</div>
<button
type="button"
onClick={() => setShowFilterModal(false)}
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Hashtag Filters */}
<div className="space-y-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted flex items-center gap-1">
<Hash className="w-3 h-3 text-accent" />
<span>Nach Hashtags filtern</span>
</span>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setSelectedTag(null)}
className={`min-h-[32px] px-3 text-xs rounded-full border transition-colors cursor-pointer ${
selectedTag === null
? 'bg-accent text-on-accent border-accent font-semibold'
: 'bg-canvas border-border text-muted'
}`}
>
Alle ({safeTasks.length})
</button>
{allAvailableTags.map((tag) => {
const count = safeTasks.filter((t) => t.tags?.includes(tag)).length;
const isSelected = selectedTag === tag;
return (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(isSelected ? null : tag)}
className={`min-h-[32px] px-3 text-xs rounded-full border transition-colors flex items-center gap-1 cursor-pointer ${
isSelected
? 'bg-accent text-on-accent border-accent font-semibold'
: 'bg-canvas border-border text-muted'
}`}
>
<span>#{tag}</span>
<span className="text-[10px] opacity-75">({count})</span>
</button>
);
})}
</div>
</div>
{/* Grouping */}
{viewMode !== 'kanban' && (
<div className="space-y-2 pt-2 border-t border-border">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted block">
Gruppierung
</span>
<div className="grid grid-cols-2 gap-1.5">
{[
{ id: 'none', label: 'Keine' },
{ id: 'tag', label: 'Nach Hashtag' },
{ id: 'status', label: 'Nach Status' },
{ id: 'priority', label: 'Nach Priorität' },
{ id: 'due', label: 'Nach Fälligkeit' },
].map((g) => (
<button
key={g.id}
type="button"
onClick={() => setGroupBy(g.id as any)}
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
groupBy === g.id
? 'bg-accent-dim border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{g.label}
</button>
))}
</div>
</div>
)}
{/* Sorting */}
{viewMode !== 'kanban' && (
<div className="space-y-2 pt-2 border-t border-border">
<span className="text-[11px] font-bold uppercase tracking-wider text-muted block">
Sortierung
</span>
<div className="grid grid-cols-2 gap-1.5">
{[
{ id: 'custom', label: 'Manuell (Drag & Drop)' },
{ id: 'created', label: 'Erstelldatum' },
{ id: 'due', label: 'Fälligkeitsdatum' },
{ id: 'priority', label: 'Priorität' },
{ id: 'alpha', label: 'Alphabetisch' },
].map((s) => (
<button
key={s.id}
type="button"
onClick={() => setSortBy(s.id as any)}
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
sortBy === s.id
? 'bg-accent-dim border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
{s.label}
</button>
))}
</div>
</div>
)}
<div className="pt-3 border-t border-border flex items-center justify-between gap-2">
<button
type="button"
onClick={() => {
setSelectedTag(null);
setGroupBy('none');
setSortBy('custom');
}}
className="btn-ghost h-9 px-3 text-xs text-muted hover:text-main cursor-pointer"
>
Filter zurücksetzen
</button>
<button
type="button"
onClick={() => setShowFilterModal(false)}
className="btn-primary h-9 px-5 text-xs font-semibold cursor-pointer"
>
Fertig
</button>
</div>
</div>
</div>
)}
{/* NEW LIST MODAL */}
{showNewListModal && (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
<Layers className="w-4 h-4 text-accent" />
<span>Neue Aufgabenliste erstellen</span>
</h3>
<button
onClick={() => setShowNewListModal(false)}
className="text-muted hover:text-main"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleCreateTaskList} className="space-y-3">
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-10 h-8 text-center input-field text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Homelab, Einkäufe, Steuern"
autoFocus
required
className="flex-1 h-8 input-field px-2.5 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowNewListModal(false)}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button type="submit" className="btn-primary h-7 px-3 text-xs">
Erstellen
</button>
</div>
</form>
</div>
</div>
)}
{/* UNIFIED SHARE LIST MODAL */}
{showShareListModal && sharingTaskList && (
<UnifiedShareModal
isOpen={showShareListModal}
onClose={() => {
setShowShareListModal(false);
setSharingTaskList(null);
}}
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
resourceName={sharingTaskList.title}
resourceIcon={sharingTaskList.icon || '📋'}
isOwner={sharingTaskList.isOwner !== false}
currentUserId={currentUser?.id}
shares={(sharingTaskList.shares || []).map((sh) => ({
id: sh.id,
targetType: sh.targetType,
targetId: sh.targetId,
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email,
avatarUrl: sh.avatarUrl,
permission: sh.permission === 'viewer' ? 'viewer' as const : 'editor' as const,
role: sh.permission === 'viewer' ? 'viewer' : 'editor',
createdAt: sh.createdAt,
}))}
teams={safeTeams}
collaborators={safeCollaborators}
onAddShare={async (targetType, targetId, permission) => {
await api.addTaskListShare(sharingTaskList.id, {
targetType,
targetId,
permission,
});
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
}}
onRemoveShare={async (shareId) => {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
}}
/>
)}
{/* MANAGE COLUMNS MODAL */}
<ManageColumnsModal
isOpen={showManageColumnsModal}
onClose={() => setShowManageColumnsModal(false)}
initialColumns={activeColumns}
onSave={handleSaveColumns}
listTitle={currentSelectedList?.title || 'Alle Aufgaben'}
/>
{/* TASK DETAIL MODAL */}
<TaskDetailModal
task={detailTask}
isOpen={showDetailModal}
onClose={() => {
setShowDetailModal(false);
setDetailTaskId(null);
}}
activeColumns={activeColumns}
taskLists={safeTaskLists}
onOpenDocument={onOpenDocument}
onUpdateTitle={handleUpdateTaskTitle}
onUpdateStatus={handleSetKanbanStatus}
onUpdatePriority={handleSetPriority}
onUpdateDueDate={handleUpdateTaskDueDate}
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
onToggleSubtask={handleToggleSubtask}
onAddSubtask={handleAddSubtask}
onDeleteSubtask={handleDeleteSubtask}
onConvertToDoc={handleConvertToDoc}
onDeleteTask={handleDeletePermanent}
readOnly={detailTask ? isTaskReadOnly(detailTask) : false}
/>
{/* VIEW MODES: LIST OR KANBAN */}
{viewMode === 'kanban' ? (
/* DYNAMIC KANBAN BOARD */
<div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 pt-1">
{activeColumns.map((col, colIdx) => {
const colTasks = kanbanTasks.filter((t) => {
if (col.isDone) {
return t.isCompleted || t.kanbanStatus === col.id;
}
if (t.isCompleted) return false;
if (t.kanbanStatus) return t.kanbanStatus === col.id;
return col.id === 'todo';
});
return (
<div
key={col.id}
onDragOver={(e) => {
e.preventDefault();
if (draggingTaskId) setDragOverKanbanCol(col.id);
}}
onDragLeave={() => {
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
}}
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
className={`bg-canvas border border-border flex flex-col min-h-[420px] transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
}`}
>
{/* Column Header */}
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${
col.isDone
? 'bg-status-ok'
: col.id === 'in_progress'
? 'bg-accent'
: col.id === 'waiting'
? 'bg-status-warn'
: 'bg-faint'
}`}
/>
<span>{col.name}</span>
</span>
<span className="badge-neutral font-mono">{colTasks.length}</span>
</div>
{/* Tasks List in Column */}
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
{colTasks.map((task) => {
const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
const readOnly = isTaskReadOnly(task);
return (
<div
key={task.id}
draggable={!readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
}}
onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task, col.id)}
onClick={() => {
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
}`}
>
{/* Drop insertion line indicator */}
{isTarget && dragOverTaskTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverTaskTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-start justify-between gap-2">
<p
className={`text-xs font-medium break-words flex-1 leading-snug ${
task.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{task.content}
</p>
{!readOnly && (
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => handleConvertToDoc(task)}
title="Als Notiz vertiefen"
className="text-faint hover:text-accent p-0.5"
>
<FileText className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeletePermanent(task.id)}
title="Aufgabe löschen"
className="text-faint hover:text-status-err p-0.5"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
{/* Subtasks progress */}
{renderTaskProgressBar(task)}
{/* Notes preview badge */}
{task.notes && (
<div className="flex items-center gap-1 text-[10px] text-muted bg-canvas p-1 rounded border border-border truncate">
<FileText className="w-2.5 h-2.5 text-accent flex-shrink-0" />
<span className="truncate">{task.notes}</span>
</div>
)}
{/* Badges: In Notiz, Priority, Status, Tags */}
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}>
{(task.convertedType === 'document_block' || task.docId) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId) onOpenDocument(docId, blockId);
}}
className="h-4 px-1.5 text-[9px] rounded-btn border border-accent/40 bg-accent/10 text-accent hover:bg-accent/20 cursor-pointer flex items-center gap-1 font-medium shrink-0"
title="In Notiz an der passenden Stelle öffnen"
>
<FileText className="w-2.5 h-2.5" />
<span>In Notiz</span>
</button>
)}
{task.priority === 'urgent' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="badge-accent text-[9px] cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
>
🔥 Dringend
</button>
)}
{task.priority === 'high' && (
<button
type="button"
disabled={readOnly}
onClick={() => handleCyclePriority(task)}
className="px-1.5 py-0.2 border border-status-warn text-status-warn rounded-btn text-[9px] font-medium cursor-pointer hover:opacity-85 disabled:cursor-default"
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
>
⚠️ Wichtig
</button>
)}
{task.source?.toLowerCase() === 'webhook' && (
<span
className="px-1.5 py-0.2 border border-border bg-canvas text-muted rounded-btn text-[9px] font-medium flex items-center gap-1"
title="Erstellt über Webhook"
>
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
{task.dueDate && (
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
<Calendar className="w-2.5 h-2.5" />
<span>{task.dueDate}</span>
</span>
)}
{task.tags?.map((tag) => (
<button
key={tag}
type="button"
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
className={`text-[9px] px-1.5 py-0.2 rounded-pill border transition-colors ${
selectedTag === tag
? 'bg-accent/15 border-accent text-accent font-semibold'
: 'badge-neutral hover:border-accent'
}`}
>
#{tag}
</button>
))}
</div>
{/* Column Navigation controls: Move Left / Move Right */}
{!readOnly && (
<div
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
disabled={colIdx === 0}
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx - 1].id)}
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
title={colIdx > 0 ? `Nach "${activeColumns[colIdx - 1].name}"` : ''}
>
<ArrowLeft className="w-3 h-3" />
<span className="hidden sm:inline">{colIdx > 0 ? activeColumns[colIdx - 1].name : ''}</span>
</button>
<button
type="button"
disabled={colIdx === activeColumns.length - 1}
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx + 1].id)}
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
title={colIdx < activeColumns.length - 1 ? `Nach "${activeColumns[colIdx + 1].name}"` : ''}
>
<span className="hidden sm:inline">{colIdx < activeColumns.length - 1 ? activeColumns[colIdx + 1].name : ''}</span>
<ArrowRight className="w-3 h-3" />
</button>
</div>
)}
</div>
);
})}
{colTasks.length === 0 && (
<div className="p-4 text-center text-faint text-xs italic">
Keine Aufgaben in dieser Spalte
</div>
)}
</div>
{/* Quick Add Task to Column */}
{!isSelectedListReadOnly && (
<div className="p-2 border-t border-border bg-panel">
<button
type="button"
onClick={() => handleQuickAddTaskToColumn(col.id)}
className="w-full h-7 px-2 text-xs border border-dashed border-border rounded-btn text-muted hover:text-main hover:bg-canvas flex items-center justify-center gap-1 transition-colors"
>
<Plus className="w-3 h-3" />
<span>Aufgabe hinzufügen</span>
</button>
</div>
)}
</div>
);
})}
</div>
</div>
) : (
/* STANDARD LIST VIEW (CLEAN & UNCLUTTERED) */
<div className="space-y-2">
{/* Subfilter tabs for List View (Desktop; on mobile controlled via MindSift Logo Menu) */}
<div className="flex items-center justify-between gap-2">
<div className="hidden sm:flex items-center border border-border rounded-btn bg-canvas overflow-hidden">
<button
onClick={() => setFilter('open')}
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
filter === 'open' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Offen ({openTasks.length})
</button>
<button
onClick={() => setFilter('done')}
className={`h-8 px-3.5 text-xs font-medium border-r border-border transition-colors ${
filter === 'done' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Erledigt ({doneTasks.length})
</button>
<button
onClick={() => setFilter('discarded')}
className={`h-8 px-3.5 text-xs font-medium transition-colors ${
filter === 'discarded' ? 'bg-panel text-main font-semibold' : 'text-muted hover:text-main'
}`}
>
Papierkorb ({safeDiscarded.length})
</button>
</div>
{filter === 'discarded' && safeDiscarded.length > 0 && (
<button onClick={handlePurgeAllDiscarded} className="btn-action min-h-[38px] sm:min-h-0">
<Trash2 className="w-3.5 h-3.5" />
Papierkorb leeren
</button>
)}
</div>
{loading ? (
<div className="p-6 text-center text-faint text-sm">Lade Aufgaben…</div>
) : filter === 'discarded' ? (
<div className="panel-surface divide-y divide-border">
{safeDiscarded.length === 0 ? (
<div className="px-4 py-6 text-center text-muted text-sm">
Keine verworfenen Gedanken im Papierkorb.
</div>
) : (
safeDiscarded.map((item) => (
<div
key={item.id}
className="px-3 py-2 flex items-center justify-between gap-3 hover:bg-panel-hover"
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<Trash2 className="w-4 h-4 text-faint flex-shrink-0" />
<p className="text-sm text-muted line-through truncate">{item.content}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleRestore(item)} className="btn-action">
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
</button>
<button
onClick={() => handleDeletePermanent(item.id)}
className="btn-icon btn-icon-danger"
title="Endgültig löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
) : (
<div className="space-y-3">
{groupedTasks ? (
/* GROUPED LIST */
groupedTasks.map((grp) => (
<div key={grp.key} className="panel-surface overflow-hidden">
<div className="px-3 py-1.5 bg-panel border-b border-border flex items-center justify-between">
<span className="text-xs font-semibold text-main">{grp.title}</span>
<span className="badge-neutral font-mono text-[10px]">{grp.count}</span>
</div>
<div>
{grp.tasks.map((task) => renderTaskRow(task))}
</div>
</div>
))
) : (
/* FLAT LIST */
<div className="panel-surface divide-y divide-border overflow-hidden">
{filteredTasks.length === 0 ? (
<div className="p-8 text-center text-muted text-sm space-y-1">
<p className="font-medium text-main">Keine Aufgaben gefunden</p>
<p className="text-xs text-faint">
{selectedTag
? `Keine Aufgaben mit Hashtag #${selectedTag}.`
: filter === 'done'
? 'Noch keine Aufgaben erledigt.'
: 'Erstelle oben eine neue Aufgabe.'}
</p>
</div>
) : (
filteredTasks.map((task) => renderTaskRow(task))
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
};