Files
MindSift/frontend/src/features/triage/TasksView.tsx
T
sandrodachandClaude Opus 5.5 f76b0f34ff
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
feat: Kanban touch drag, admins delete backups, bigger share message
- Kanban on phones/tablets: hold a card, drag it onto a card or column, release; the page scrolls at the edges
- Admin settings: database backups can be deleted (DELETE /api/v1/admin/backups/{name})
- Share message: large Liste/Aufgabe buttons, close button, tap outside closes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:54:17 +01:00

2646 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useRef, useMemo } from 'react';
import {
Check,
Plus,
Calendar,
Trash2,
RotateCcw,
FileText,
LayoutList,
Columns,
Maximize2,
Minimize2,
ArrowRight,
ArrowLeft,
Edit3,
X,
Layers,
Users,
CheckSquare,
User as
Lock,
Hash,
AlertTriangle,
Flame,
Settings,
Folder,
Filter,
Webhook,
Repeat,
} from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext';
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
// longer tap, so the row would not open
const canDragTasks =
typeof window !== 'undefined' && !!window.matchMedia?.('(any-pointer: fine)').matches;
// Rows inside a task card keep taps on their own controls; taps on empty space or plain labels
// (due date, webhook) reach the card and open the task
const stopOnControl = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, select, a, input')) e.stopPropagation();
};
// Same rule as the server's task query: a fresh Quick Dump note (status inbox) is no task yet
const isTaskThought = (t: Thought) =>
t.status === 'kept' || (t.status === 'converted' && t.convertedType === 'task');
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number;
initialSelectedTaskId?: string | null;
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
}
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
// Wide Kanban: a column grows to at most this width, the board stays centred
const KANBAN_COL_MAX = 380;
const KANBAN_GAP = 12; // gap-3
// Only the board leaves the page column (header and "new task" keep their width): its width follows
// the columns (at most KANBAN_COL_MAX each, at most the window) and a margin keeps it centred – a
// negative one when it is wider than the page column. Width and margin are animated.
const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties => {
if (!wide) return { '--kanban-w': '100%', '--kanban-ml': '0px' } as React.CSSProperties;
const w = `min(100vw - 4rem, ${columns * KANBAN_COL_MAX + (columns - 1) * KANBAN_GAP}px)`;
return {
'--kanban-cols': columns,
'--kanban-w': w,
'--kanban-ml': `calc((100% - ${w}) / 2)`,
} as React.CSSProperties;
};
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
export const TasksView: React.FC<TasksViewProps> = ({
currentUser,
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
initialSelectedTaskId,
openTasksCount,
}) => {
// Open list and view survive switching to other tabs (like the workspace's open note)
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
);
// Wide Kanban: the board takes the whole window and shows every column in one row
const [kanbanWide, setKanbanWide] = useState(() => {
try {
return localStorage.getItem(KANBAN_WIDE_KEY) === '1';
} catch {
return false;
}
});
const toggleKanbanWide = () => {
const next = !kanbanWide;
setKanbanWide(next);
try {
localStorage.setItem(KANBAN_WIDE_KEY, next ? '1' : '0');
} catch {
// only a convenience
}
};
const isKanbanWide = viewMode === 'kanban' && kanbanWide;
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
const [tasks, setTasks] = useState<Thought[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [selectedListId, setSelectedListId] = useState<string>(
() => readViewSession<TasksSession>('tasks').listId || 'all'
);
useEffect(() => {
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
}, [selectedListId, viewMode]);
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);
// List tab a dragged task hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = 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);
// 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');
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
// the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that
// happened to fall into the reload (press and release then hit different elements).
const loadedListRef = useRef<string | null>(null);
const loadSeqRef = useRef(0);
const loadData = async () => {
const seq = ++loadSeqRef.current;
const listId = selectedListId;
if (loadedListRef.current !== listId) setLoading(true);
try {
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
api.getTaskLists().catch(() => []),
api.getUserTeams().catch(() => []),
api.getCollaborators().catch(() => []),
api.getThoughts('discarded').catch(() => []),
]);
if (seq !== loadSeqRef.current) return; // a newer reload is under way
loadedListRef.current = listId;
setTasks(Array.isArray(allTasks) ? allTasks : []);
setTaskLists(Array.isArray(allLists) ? allLists : []);
// The remembered list was deleted or unshared meanwhile
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
setSelectedListId('all');
}
setTeams(Array.isArray(allTeams) ? allTeams : []);
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
} catch {
// Prevent unhandled promise rejection from crashing view
} finally {
if (seq === loadSeqRef.current) 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 && isTaskThought(created)) {
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 (
isTaskThought(updated) &&
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
) {
return [updated, ...prev];
}
return prev;
}
// Undone back into the Quick Dump, discarded, or moved to another list
if (
!isTaskThought(updated) ||
(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();
} else if (msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
// Payload only carries the list ID; reload so permissions reflect this user's view
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
}
};
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';
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
// assignees who lost access before the task payload caught up
const membersByList = useMemo(() => {
const m = new Map<string, TaskListMember[]>();
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
return m;
}, [safeTaskLists]);
const assignableMembers = (task: Thought): TaskListMember[] => {
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
return members.length > 1 ? members : [];
};
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
id: m.userId,
name: m.displayName || m.email,
avatarUrl: m.avatarUrl,
});
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
const members = assignableMembers(task);
return (task.assignees || [])
.map((id) => members.find((m) => m.userId === id))
.filter((m): m is TaskListMember => !!m)
.map(memberToPerson);
};
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
const people = getAssigneePeople(task);
if (people.length === 0) return null;
return (
<AvatarStack
people={people}
size={size}
ringClass="ring-[var(--bg-panel)]"
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
/>
);
};
// 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 now = new Date();
const today = localDay(now);
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > 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();
};
// A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status
// if the list has that column, otherwise it starts in the list's first column
const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId;
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(null);
const task = tasks.find((t) => t.id === sourceId);
if (!task || task.taskListId === list.id || list.permission === 'viewer') return;
triggerHaptic(15);
const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns;
const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus);
const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id;
setTasks((prev) =>
selectedListId !== 'all'
? prev.filter((t) => t.id !== task.id)
: prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t))
);
try {
await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) });
} catch {
loadData();
}
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
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 handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
try {
await api.updateThought(task.id, { recurrence });
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
}
};
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
triggerHaptic(10);
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
try {
const updated = await api.updateThought(task.id, { assignees });
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
} catch {
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
}
};
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);
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
};
// Shared by mouse drag & drop and the touch drag on the Kanban board
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
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);
await moveTaskToColumn(sourceId, colId);
};
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
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();
}
};
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
const clearDragState = () => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
};
const kanbanTouch = useKanbanTouchDrag({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
onHover: (colId, target) => {
setDragOverKanbanCol(colId);
setDragOverTaskTarget(target);
},
onDrop: (taskId, colId, target) => {
clearDragState();
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
else if (colId) void moveTaskToColumn(taskId, colId);
},
onCancel: clearDragState,
});
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);
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 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" />,
badge: openTasksCount || undefined,
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,
openTasksCount,
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={canDragTasks && !readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(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 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={stopOnControl}
>
{(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] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5 text-accent" />
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-3 h-3" />
</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>
{renderAssignees(task, 'xs')}
{/* 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'
}`}
>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
Alle Aufgaben
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
{safeTaskLists.map((tl) => (
<div
key={tl.id}
onDragOver={(e) => {
const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null;
if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverListTab !== tl.id) setDragOverListTab(tl.id);
}}
onDragLeave={() => {
if (dragOverListTab === tl.id) setDragOverListTab(null);
}}
onDrop={(e) => handleDropOnListTab(e, tl)}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
} ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`}
>
<button
onClick={() => setSelectedListId(tl.id)}
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{tl.icon}</span>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
{tl.title}
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
</span>
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || (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 (
<AvatarStack
people={otherMembers.map(memberToPerson)}
size="xxs"
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
);
}
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-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-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 || ((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) */}
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] 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>
)}
<button
type="button"
onClick={toggleKanbanWide}
aria-pressed={kanbanWide}
className="btn-action h-7 w-7 p-0 hidden lg:inline-flex items-center justify-center"
title={kanbanWide ? 'Board wieder normal breit' : 'Board auf volle Breite strecken'}
>
{kanbanWide ? (
<Minimize2 className="w-3.5 h-3.5 text-accent" />
) : (
<Maximize2 className="w-3.5 h-3.5 text-accent" />
)}
</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 && (
<FormSheet
title="Neue Aufgabenliste erstellen"
icon={<Layers className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateTaskList}
>
<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-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm: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 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
</FormSheet>
)}
{/* 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}
onUpdateRecurrence={handleUpdateTaskRecurrence}
onUpdateTaskList={handleUpdateTaskTaskList}
onUpdateNotes={handleUpdateTaskNotes}
onUpdateTags={handleUpdateTaskTags}
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
onUpdateAssignees={handleUpdateTaskAssignees}
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">
{kanbanTouch.ghost}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
}`}
style={kanbanBoardStyle(isKanbanWide, activeColumns.length)}
>
{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)}
data-kanban-col={col.id}
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden 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={canDragTasks && !readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)}
onDragEnd={() => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
setDragOverListTab(null);
}}
onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task, col.id)}
data-kanban-task={task.id}
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
onClick={() => {
if (kanbanTouch.consumeClick()) return;
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
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={stopOnControl}>
{(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] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
>
<Calendar className="w-2.5 h-2.5" />
<span>{formatDue(task.dueDate)}</span>
</span>
)}
{task.recurrence && (
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
<Repeat className="w-2.5 h-2.5" />
</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>
))}
{getAssigneePeople(task).length > 0 && (
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
)}
</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={stopOnControl}
>
<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>
);
};