MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
- 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>
2646 lines
109 KiB
TypeScript
2646 lines
109 KiB
TypeScript
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>
|
||
);
|
||
};
|