MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
1973 lines
77 KiB
TypeScript
1973 lines
77 KiB
TypeScript
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
|
import {
|
|
Check,
|
|
Plus,
|
|
Calendar,
|
|
Trash2,
|
|
RotateCcw,
|
|
FileText,
|
|
LayoutList,
|
|
Columns,
|
|
ArrowRight,
|
|
ArrowLeft,
|
|
Edit3,
|
|
X,
|
|
Layers,
|
|
Users,
|
|
CheckSquare,
|
|
User as UserIcon,
|
|
Lock,
|
|
Hash,
|
|
AlertTriangle,
|
|
Flame,
|
|
Settings,
|
|
ChevronDown,
|
|
Folder,
|
|
} from 'lucide-react';
|
|
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types';
|
|
import { api, triggerHaptic } from '../../lib/api';
|
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
|
import { TaskDetailModal } from './TaskDetailModal';
|
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
|
import { UserAutocomplete } from '../UserAutocomplete';
|
|
import { useMobileActions } from '../../context/MobileActionContext';
|
|
|
|
const getInitials = (name?: string): string => {
|
|
if (!name) return '?';
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
}
|
|
return name.slice(0, 2).toUpperCase();
|
|
};
|
|
|
|
interface TasksViewProps {
|
|
currentUser?: User | null;
|
|
onRefreshStats: () => void;
|
|
onOpenDocument: (docId: string) => void;
|
|
refreshTrigger?: number;
|
|
}
|
|
|
|
export const TasksView: React.FC<TasksViewProps> = ({
|
|
currentUser,
|
|
onRefreshStats,
|
|
onOpenDocument,
|
|
refreshTrigger = 0,
|
|
}) => {
|
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
|
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
|
const [tasks, setTasks] = useState<Thought[]>([]);
|
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
|
const [selectedListId, setSelectedListId] = useState<string>('all');
|
|
const [teams, setTeams] = useState<Team[]>([]);
|
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
|
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Drag and Drop state
|
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
|
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
|
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
|
|
|
// Quick Task Add state
|
|
const [quickTitle, setQuickTitle] = useState('');
|
|
const [quickDueDate, setQuickDueDate] = useState('');
|
|
const quickInputRef = useRef<HTMLInputElement | null>(null);
|
|
const { setGroups } = useMobileActions();
|
|
|
|
// New TaskList modal state
|
|
const [showNewListModal, setShowNewListModal] = useState(false);
|
|
const [newListTitle, setNewListTitle] = useState('');
|
|
const [newListIcon, setNewListIcon] = useState('📋');
|
|
|
|
// Share TaskList modal state
|
|
const [showShareListModal, setShowShareListModal] = useState(false);
|
|
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
|
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
|
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
|
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
|
const [shareLoading, setShareLoading] = useState(false);
|
|
|
|
// Detail modal state
|
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
|
|
|
// Manage columns modal state
|
|
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
|
|
const [globalColumns, setGlobalColumns] = useState<KanbanColumn[]>(() => {
|
|
try {
|
|
const stored = localStorage.getItem('mindsift:kanban_columns');
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
|
}
|
|
} catch {}
|
|
return DEFAULT_KANBAN_COLUMNS;
|
|
});
|
|
|
|
// Hashtag filtering, grouping and sorting state
|
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
|
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
|
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
|
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
|
|
api.getTaskLists().catch(() => []),
|
|
api.getUserTeams().catch(() => []),
|
|
api.getCollaborators().catch(() => []),
|
|
api.getThoughts('discarded').catch(() => []),
|
|
]);
|
|
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
|
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
|
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
|
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
|
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
|
} catch {
|
|
// Prevent unhandled promise rejection from crashing view
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [refreshTrigger, selectedListId]);
|
|
|
|
const selectedListIdRef = useRef(selectedListId);
|
|
selectedListIdRef.current = selectedListId;
|
|
|
|
const onRefreshStatsRef = useRef(onRefreshStats);
|
|
onRefreshStatsRef.current = onRefreshStats;
|
|
|
|
// Live-Sync WebSocket listener for tasks (registered once on mount)
|
|
useEffect(() => {
|
|
const handleWs = (e: any) => {
|
|
const msg = e.detail;
|
|
if (!msg || !msg.event) return;
|
|
|
|
if (msg.event === 'TASK_CREATED') {
|
|
const created = msg.payload as Thought;
|
|
if (
|
|
created &&
|
|
(created.status === 'converted' ||
|
|
created.kanbanStatus ||
|
|
created.taskListId ||
|
|
created.isCompleted !== undefined)
|
|
) {
|
|
setTasks((prev) => {
|
|
if (prev.some((t) => t.id === created.id)) return prev;
|
|
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
|
|
return [created, ...prev];
|
|
});
|
|
onRefreshStatsRef.current();
|
|
}
|
|
} else if (msg.event === 'TASK_UPDATED') {
|
|
const updated = msg.payload as Thought;
|
|
if (updated && updated.id) {
|
|
setTasks((prev) => {
|
|
const exists = prev.some((t) => t.id === updated.id);
|
|
if (!exists) {
|
|
if (
|
|
(updated.status === 'converted' ||
|
|
updated.kanbanStatus ||
|
|
updated.taskListId ||
|
|
updated.isCompleted !== undefined) &&
|
|
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
|
|
) {
|
|
return [updated, ...prev];
|
|
}
|
|
return prev;
|
|
}
|
|
if (selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current) {
|
|
return prev.filter((t) => t.id !== updated.id);
|
|
}
|
|
return prev.map((t) => (t.id === updated.id ? updated : t));
|
|
});
|
|
onRefreshStatsRef.current();
|
|
}
|
|
} else if (msg.event === 'TASK_DELETED') {
|
|
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
|
if (deletedId) {
|
|
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
|
onRefreshStatsRef.current();
|
|
}
|
|
} else if (msg.event === 'TASK_REORDERED') {
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('mindsift:ws', handleWs);
|
|
return () => window.removeEventListener('mindsift:ws', handleWs);
|
|
}, []);
|
|
|
|
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
|
|
const currentSelectedList = safeTaskLists.find((l) => l.id === selectedListId);
|
|
|
|
// 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) || null;
|
|
}, [detailTaskId, tasks]);
|
|
|
|
const safeTeams = Array.isArray(teams) ? teams : [];
|
|
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
|
|
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
|
|
|
|
// Available unique hashtags from current tasks
|
|
const allAvailableTags = useMemo(() => {
|
|
const set = new Set<string>();
|
|
for (const t of safeTasks) {
|
|
if (Array.isArray(t.tags)) {
|
|
for (const tg of t.tags) {
|
|
if (tg) set.add(tg);
|
|
}
|
|
}
|
|
}
|
|
return Array.from(set).sort();
|
|
}, [safeTasks]);
|
|
|
|
// Filtered tasks for List view
|
|
const openTasks = safeTasks.filter((t) => !t.isCompleted);
|
|
const doneTasks = safeTasks.filter((t) => t.isCompleted);
|
|
|
|
const filteredTasks = useMemo(() => {
|
|
let result = safeTasks;
|
|
if (filter === 'open') {
|
|
result = result.filter((t) => !t.isCompleted);
|
|
} else if (filter === 'done') {
|
|
result = result.filter((t) => t.isCompleted);
|
|
}
|
|
if (selectedTag) {
|
|
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
|
|
}
|
|
if (sortBy === 'custom') {
|
|
return [...result].sort((a, b) => {
|
|
const orderA = a.sortOrder ?? 0;
|
|
const orderB = b.sortOrder ?? 0;
|
|
if (orderA !== orderB) return orderA - orderB;
|
|
return b.createdAt.localeCompare(a.createdAt);
|
|
});
|
|
}
|
|
return [...result].sort((a, b) => {
|
|
if (sortBy === 'due') {
|
|
if (!a.dueDate && !b.dueDate) return 0;
|
|
if (!a.dueDate) return 1;
|
|
if (!b.dueDate) return -1;
|
|
return a.dueDate.localeCompare(b.dueDate);
|
|
}
|
|
if (sortBy === 'priority') {
|
|
const pScore: Record<string, number> = { urgent: 3, high: 2, normal: 1, low: 0 };
|
|
const scoreA = pScore[a.priority] || 1;
|
|
const scoreB = pScore[b.priority] || 1;
|
|
return scoreB - scoreA;
|
|
}
|
|
if (sortBy === 'alpha') {
|
|
return a.content.localeCompare(b.content);
|
|
}
|
|
return b.createdAt.localeCompare(a.createdAt);
|
|
});
|
|
}, [safeTasks, filter, selectedTag, sortBy]);
|
|
|
|
// Tasks for Kanban board (shows all non-discarded matching tag)
|
|
const kanbanTasks = useMemo(() => {
|
|
let result = safeTasks;
|
|
if (selectedTag) {
|
|
result = result.filter((t) => Array.isArray(t.tags) && t.tags.includes(selectedTag));
|
|
}
|
|
return result;
|
|
}, [safeTasks, selectedTag]);
|
|
|
|
// Grouped tasks calculation
|
|
const groupedTasks = useMemo(() => {
|
|
if (groupBy === 'none') return null;
|
|
const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = [];
|
|
|
|
if (groupBy === 'tag') {
|
|
const map = new Map<string, Thought[]>();
|
|
const withoutTag: Thought[] = [];
|
|
for (const t of filteredTasks) {
|
|
if (!t.tags || t.tags.length === 0) {
|
|
withoutTag.push(t);
|
|
} else {
|
|
for (const tg of t.tags) {
|
|
if (!map.has(tg)) map.set(tg, []);
|
|
map.get(tg)!.push(t);
|
|
}
|
|
}
|
|
}
|
|
Array.from(map.keys()).sort().forEach((tg) => {
|
|
const list = map.get(tg)!;
|
|
groups.push({ key: `tag:${tg}`, title: `#${tg}`, count: list.length, tasks: list });
|
|
});
|
|
if (withoutTag.length > 0) {
|
|
groups.push({ key: 'tag:none', title: 'Ohne Hashtag', count: withoutTag.length, tasks: withoutTag });
|
|
}
|
|
} else if (groupBy === 'status') {
|
|
activeColumns.forEach((col) => {
|
|
const list = filteredTasks.filter((t) => {
|
|
if (col.isDone) return t.isCompleted || t.kanbanStatus === col.id;
|
|
return !t.isCompleted && (t.kanbanStatus === col.id || (!t.kanbanStatus && col.id === 'todo'));
|
|
});
|
|
groups.push({ key: `status:${col.id}`, title: col.name, count: list.length, tasks: list });
|
|
});
|
|
} else if (groupBy === 'priority') {
|
|
const urgent = filteredTasks.filter((t) => t.priority === 'urgent');
|
|
const high = filteredTasks.filter((t) => t.priority === 'high');
|
|
const normal = filteredTasks.filter((t) => t.priority !== 'urgent' && t.priority !== 'high');
|
|
if (urgent.length > 0) groups.push({ key: 'p:urgent', title: '🔥 Dringend', count: urgent.length, tasks: urgent });
|
|
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
|
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
|
} else if (groupBy === 'due') {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
|
|
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
|
|
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
|
|
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
|
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
|
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
|
if (upcoming.length > 0) groups.push({ key: 'due:upcoming', title: 'Demnächst', count: upcoming.length, tasks: upcoming });
|
|
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
|
|
}
|
|
return groups;
|
|
}, [filteredTasks, groupBy, activeColumns]);
|
|
|
|
// --- TASK ACTIONS ---
|
|
|
|
const handleToggleDone = async (task: Thought) => {
|
|
triggerHaptic(15);
|
|
const nextState = !task.isCompleted;
|
|
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
|
|
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
|
|
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
|
|
|
setTasks((prev) =>
|
|
prev.map((t) =>
|
|
t.id === task.id ? { ...t, isCompleted: nextState, kanbanStatus: nextKanban } : t
|
|
)
|
|
);
|
|
await api.updateThought(task.id, { isCompleted: nextState, kanbanStatus: nextKanban });
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleSetKanbanStatus = async (task: Thought, nextStatus: string) => {
|
|
triggerHaptic(15);
|
|
const targetCol = activeColumns.find((c) => c.id === nextStatus);
|
|
const isCompleted = targetCol ? !!targetCol.isDone : nextStatus === 'done';
|
|
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === task.id ? { ...t, kanbanStatus: nextStatus, isCompleted } : t))
|
|
);
|
|
await api.updateThought(task.id, { kanbanStatus: nextStatus, isCompleted });
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleSetPriority = async (task: Thought, nextPriority: string) => {
|
|
triggerHaptic(10);
|
|
const normalized = nextPriority === 'none' || nextPriority === '' ? 'normal' : nextPriority;
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === task.id ? { ...t, priority: normalized as any } : t))
|
|
);
|
|
await api.updateThought(task.id, { priority: normalized });
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleCyclePriority = async (task: Thought) => {
|
|
triggerHaptic(10);
|
|
const nextPriority =
|
|
task.priority === 'urgent'
|
|
? 'normal'
|
|
: task.priority === 'high'
|
|
? 'urgent'
|
|
: 'high';
|
|
await handleSetPriority(task, nextPriority);
|
|
};
|
|
|
|
const handleUpdateTaskTitle = async (task: Thought, newTitle: string) => {
|
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, content: newTitle } : t)));
|
|
await api.updateThought(task.id, { content: newTitle });
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleUpdateTaskDueDate = async (task: Thought, newDate: string) => {
|
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, dueDate: newDate } : t)));
|
|
await api.updateThought(task.id, { dueDate: newDate });
|
|
};
|
|
|
|
const handleUpdateTaskTaskList = async (task: Thought, newListId: string) => {
|
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, taskListId: newListId } : t)));
|
|
await api.updateThought(task.id, { taskListId: newListId });
|
|
if (selectedListId !== 'all' && selectedListId !== newListId) {
|
|
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
|
}
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
|
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
|
|
await api.updateThought(task.id, { notes: newNotes });
|
|
};
|
|
|
|
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
|
await api.updateThought(task.id, { tags: newTags });
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleToggleSubtask = async (task: Thought, subtaskId: string) => {
|
|
triggerHaptic(10);
|
|
const updatedSubtasks = (task.subtasks || []).map((s) =>
|
|
s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s
|
|
);
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
|
);
|
|
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
|
};
|
|
|
|
const handleAddSubtask = async (task: Thought, title: string) => {
|
|
triggerHaptic(10);
|
|
const newSub: SubTask = {
|
|
id: `st_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
title: title.trim(),
|
|
isCompleted: false,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
const updatedSubtasks = [...(task.subtasks || []), newSub];
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
|
);
|
|
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
|
};
|
|
|
|
const handleDeleteSubtask = async (task: Thought, subtaskId: string) => {
|
|
triggerHaptic(10);
|
|
const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId);
|
|
setTasks((prev) =>
|
|
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
|
);
|
|
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
|
};
|
|
|
|
const handleConvertToDoc = async (task: Thought) => {
|
|
triggerHaptic(20);
|
|
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
|
onRefreshStats();
|
|
if (updated.convertedRefId) {
|
|
onOpenDocument(updated.convertedRefId);
|
|
} else {
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleRestore = async (item: Thought) => {
|
|
triggerHaptic(15);
|
|
await api.triageAction(item.id, 'restore');
|
|
await loadData();
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleDeletePermanent = async (id: string) => {
|
|
triggerHaptic(20);
|
|
await api.deleteThought(id);
|
|
await loadData();
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handlePurgeAllDiscarded = async () => {
|
|
if (!window.confirm('Alle verworfenen Gedanken endgültig löschen?')) return;
|
|
triggerHaptic(20);
|
|
await api.purgeDiscarded();
|
|
await loadData();
|
|
onRefreshStats();
|
|
};
|
|
|
|
const handleQuickAddTask = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const title = quickTitle.trim();
|
|
if (!title) return;
|
|
triggerHaptic(10);
|
|
setQuickTitle('');
|
|
|
|
try {
|
|
const created = await api.captureThought(title, 'normal', 'manual', quickDueDate, {
|
|
target: 'task',
|
|
taskListId: selectedListId === 'all' ? '' : selectedListId,
|
|
});
|
|
setTasks((prev) => {
|
|
if (prev.some((t) => t.id === created.id)) return prev;
|
|
return [created, ...prev];
|
|
});
|
|
onRefreshStats();
|
|
} catch {
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const 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();
|
|
e.dataTransfer.setData('application/mindsift-task-id', task.id);
|
|
e.dataTransfer.setData('text/plain', task.id);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
setDraggingTaskId(task.id);
|
|
};
|
|
|
|
const handleTaskDragOver = (e: React.DragEvent, targetTaskId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!draggingTaskId || draggingTaskId === targetTaskId) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
|
|
|
if (
|
|
!dragOverTaskTarget ||
|
|
dragOverTaskTarget.taskId !== targetTaskId ||
|
|
dragOverTaskTarget.position !== position
|
|
) {
|
|
setDragOverTaskTarget({ taskId: targetTaskId, position });
|
|
}
|
|
};
|
|
|
|
const handleTaskDragLeave = (e: React.DragEvent, targetTaskId: string) => {
|
|
e.stopPropagation();
|
|
if (dragOverTaskTarget?.taskId === targetTaskId) {
|
|
setDragOverTaskTarget(null);
|
|
}
|
|
};
|
|
|
|
const handleDropOnTask = async (e: React.DragEvent, targetTask: Thought, newKanbanCol?: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const sourceId =
|
|
e.dataTransfer.getData('application/mindsift-task-id') ||
|
|
e.dataTransfer.getData('text/plain') ||
|
|
draggingTaskId;
|
|
|
|
const position = dragOverTaskTarget?.position || 'after';
|
|
setDraggingTaskId(null);
|
|
setDragOverTaskTarget(null);
|
|
setDragOverKanbanCol(null);
|
|
|
|
if (!sourceId || sourceId === targetTask.id) return;
|
|
triggerHaptic(15);
|
|
|
|
const sourceTask = tasks.find((t) => t.id === sourceId);
|
|
if (!sourceTask) return;
|
|
|
|
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
|
const targetIdx = remainingTasks.findIndex((t) => t.id === targetTask.id);
|
|
if (targetIdx === -1) return;
|
|
|
|
const insertIdx = position === 'before' ? targetIdx : targetIdx + 1;
|
|
const updatedSourceTask: Thought = {
|
|
...sourceTask,
|
|
kanbanStatus: newKanbanCol || targetTask.kanbanStatus || sourceTask.kanbanStatus,
|
|
isCompleted: newKanbanCol ? newKanbanCol === 'done' : targetTask.isCompleted !== undefined ? targetTask.isCompleted : sourceTask.isCompleted,
|
|
};
|
|
|
|
const newTasks = [...remainingTasks];
|
|
newTasks.splice(insertIdx, 0, updatedSourceTask);
|
|
|
|
const updatedWithSort = newTasks.map((t, idx) => ({
|
|
...t,
|
|
sortOrder: idx,
|
|
}));
|
|
|
|
setTasks(updatedWithSort);
|
|
setSortBy('custom');
|
|
|
|
const reorderPayload = updatedWithSort.map((t) => ({
|
|
id: t.id,
|
|
sortOrder: t.sortOrder ?? 0,
|
|
kanbanStatus: t.kanbanStatus,
|
|
}));
|
|
|
|
try {
|
|
await api.reorderTasks(
|
|
reorderPayload,
|
|
selectedListId !== 'all' ? selectedListId : undefined
|
|
);
|
|
} catch {
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleDropOnKanbanCol = async (e: React.DragEvent, colId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const sourceId =
|
|
e.dataTransfer.getData('application/mindsift-task-id') ||
|
|
e.dataTransfer.getData('text/plain') ||
|
|
draggingTaskId;
|
|
|
|
setDraggingTaskId(null);
|
|
setDragOverTaskTarget(null);
|
|
setDragOverKanbanCol(null);
|
|
|
|
if (!sourceId) return;
|
|
triggerHaptic(15);
|
|
|
|
const sourceTask = tasks.find((t) => t.id === sourceId);
|
|
if (!sourceTask) return;
|
|
|
|
const isDone = colId === 'done';
|
|
const updatedSourceTask: Thought = {
|
|
...sourceTask,
|
|
kanbanStatus: colId,
|
|
isCompleted: isDone,
|
|
};
|
|
|
|
const remainingTasks = tasks.filter((t) => t.id !== sourceId);
|
|
const newTasks = [updatedSourceTask, ...remainingTasks];
|
|
|
|
const updatedWithSort = newTasks.map((t, idx) => ({
|
|
...t,
|
|
sortOrder: idx,
|
|
}));
|
|
|
|
setTasks(updatedWithSort);
|
|
setSortBy('custom');
|
|
|
|
const reorderPayload = updatedWithSort.map((t) => ({
|
|
id: t.id,
|
|
sortOrder: t.sortOrder ?? 0,
|
|
kanbanStatus: t.kanbanStatus,
|
|
}));
|
|
|
|
try {
|
|
await api.reorderTasks(
|
|
reorderPayload,
|
|
selectedListId !== 'all' ? selectedListId : undefined
|
|
);
|
|
} catch {
|
|
loadData();
|
|
}
|
|
};
|
|
|
|
const handleCreateTaskList = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const title = newListTitle.trim();
|
|
if (!title) return;
|
|
try {
|
|
const created = await api.createTaskList({
|
|
title,
|
|
icon: newListIcon,
|
|
});
|
|
const freshLists = await api.getTaskLists();
|
|
setTaskLists(freshLists);
|
|
setSelectedListId(created.id);
|
|
setShowNewListModal(false);
|
|
setNewListTitle('');
|
|
setNewListIcon('📋');
|
|
} catch (err: any) {
|
|
alert(err.message || 'Fehler beim Erstellen der Aufgabenliste');
|
|
}
|
|
};
|
|
|
|
const handleDeleteTaskList = async (listId: string) => {
|
|
if (
|
|
!window.confirm(
|
|
'Möchten Sie diese Aufgabenliste wirklich löschen? Die enthaltenen Aufgaben bleiben erhalten.'
|
|
)
|
|
)
|
|
return;
|
|
try {
|
|
await api.deleteTaskList(listId);
|
|
setTaskLists((prev) => prev.filter((l) => l.id !== listId));
|
|
setSelectedListId('all');
|
|
} catch (err: any) {
|
|
alert(err.message);
|
|
}
|
|
};
|
|
|
|
const handleOpenShareModal = async (tl: TaskList) => {
|
|
setSharingTaskList(tl);
|
|
setSelectedShareTargetId('');
|
|
setSelectedShareUser(null);
|
|
setShowShareListModal(true);
|
|
try {
|
|
const [freshTeams, freshUsers, freshShares] = await Promise.all([
|
|
api.getUserTeams().catch(() => []),
|
|
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
|
api.getTaskListShares(tl.id).catch(() => []),
|
|
]);
|
|
setTeams(freshTeams);
|
|
setCollaborators(freshUsers);
|
|
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const handleAddShare = async () => {
|
|
if (!sharingTaskList) return;
|
|
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
|
|
if (!targetId) return;
|
|
setShareLoading(true);
|
|
triggerHaptic(15);
|
|
try {
|
|
await api.addTaskListShare(sharingTaskList.id, {
|
|
targetType: addShareType,
|
|
targetId: targetId,
|
|
});
|
|
const [freshLists, freshShares] = await Promise.all([
|
|
api.getTaskLists(),
|
|
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
|
]);
|
|
setTaskLists(freshLists);
|
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
|
if (updated) {
|
|
setSharingTaskList({ ...updated, shares: freshShares });
|
|
}
|
|
setSelectedShareTargetId('');
|
|
setSelectedShareUser(null);
|
|
await loadData();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
|
} finally {
|
|
setShareLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveShare = async (shareId: string) => {
|
|
if (!sharingTaskList) return;
|
|
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
|
setShareLoading(true);
|
|
triggerHaptic(15);
|
|
try {
|
|
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
|
const [freshLists, freshShares] = await Promise.all([
|
|
api.getTaskLists(),
|
|
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
|
]);
|
|
setTaskLists(freshLists);
|
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
|
if (updated) {
|
|
setSharingTaskList({ ...updated, shares: freshShares });
|
|
}
|
|
await loadData();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
|
} finally {
|
|
setShareLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
|
|
triggerHaptic(15);
|
|
if (currentSelectedList && selectedListId !== 'all') {
|
|
const updated = await api.updateTaskList(currentSelectedList.id, { columns: newColumns });
|
|
setTaskLists((prev) => prev.map((l) => (l.id === updated.id ? updated : l)));
|
|
} else {
|
|
localStorage.setItem('mindsift:kanban_columns', JSON.stringify(newColumns));
|
|
setGlobalColumns(newColumns);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const actionItems: any[] = [
|
|
{
|
|
id: 'new-task',
|
|
label: 'Neue Aufgabe erstellen',
|
|
icon: <Plus className="w-4 h-4" />,
|
|
variant: 'accent',
|
|
onClick: () => {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
setTimeout(() => quickInputRef.current?.focus(), 100);
|
|
},
|
|
},
|
|
{
|
|
id: 'new-task-list',
|
|
label: 'Neue Aufgabenliste anlegen',
|
|
icon: <Folder className="w-4 h-4" />,
|
|
onClick: () => setShowNewListModal(true),
|
|
},
|
|
];
|
|
|
|
if (currentSelectedList) {
|
|
actionItems.push({
|
|
id: 'share-task-list',
|
|
label: `Liste „${currentSelectedList.title}“ teilen`,
|
|
icon: <Users className="w-4 h-4" />,
|
|
onClick: () => handleOpenShareModal(currentSelectedList),
|
|
});
|
|
actionItems.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') {
|
|
viewItems.push({
|
|
id: 'manage-columns',
|
|
label: 'Kanban-Spalten verwalten',
|
|
icon: <Settings className="w-4 h-4" />,
|
|
onClick: () => setShowManageColumnsModal(true),
|
|
});
|
|
}
|
|
|
|
const listItems: any[] = [
|
|
{
|
|
id: 'list-all',
|
|
label: 'Alle Aufgaben',
|
|
icon: <Layers className="w-4 h-4" />,
|
|
active: selectedListId === 'all',
|
|
onClick: () => setSelectedListId('all'),
|
|
},
|
|
...safeTaskLists.map((tl) => ({
|
|
id: `list-${tl.id}`,
|
|
label: `${tl.icon || '📋'} ${tl.title}`,
|
|
badge: tl.tasksCount || undefined,
|
|
active: selectedListId === tl.id,
|
|
onClick: () => setSelectedListId(tl.id),
|
|
})),
|
|
];
|
|
|
|
setGroups([
|
|
{
|
|
id: 'tasks-actions',
|
|
title: 'Aktionen',
|
|
items: actionItems,
|
|
},
|
|
{
|
|
id: 'tasks-views',
|
|
title: 'Ansicht & Status',
|
|
items: viewItems,
|
|
},
|
|
{
|
|
id: 'tasks-lists',
|
|
title: 'Aufgabenlisten',
|
|
items: listItems,
|
|
},
|
|
]);
|
|
}, [
|
|
viewMode,
|
|
filter,
|
|
selectedListId,
|
|
openTasks.length,
|
|
doneTasks.length,
|
|
safeDiscarded.length,
|
|
kanbanTasks.length,
|
|
safeTaskLists,
|
|
currentSelectedList,
|
|
]);
|
|
|
|
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 isDragging = draggingTaskId === task.id;
|
|
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
|
|
|
return (
|
|
<div
|
|
key={task.id}
|
|
draggable
|
|
onDragStart={(e) => handleTaskDragStart(e, task)}
|
|
onDragEnd={() => {
|
|
setDraggingTaskId(null);
|
|
setDragOverTaskTarget(null);
|
|
setDragOverKanbanCol(null);
|
|
}}
|
|
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
|
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
|
onDrop={(e) => handleDropOnTask(e, task)}
|
|
onClick={() => {
|
|
setDetailTaskId(task.id);
|
|
setShowDetailModal(true);
|
|
}}
|
|
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 transition-colors hover:bg-panel-hover group border-b border-border last:border-b-0 relative cursor-pointer cursor-grab active:cursor-grabbing select-none ${
|
|
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}
|
|
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 ${
|
|
isCompleted
|
|
? 'bg-status-ok border-status-ok text-on-accent'
|
|
: 'border-strong hover:border-accent text-transparent'
|
|
}`}
|
|
title={isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
|
|
>
|
|
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
|
|
</button>
|
|
|
|
{/* Clickable Title & Metadata */}
|
|
<div
|
|
onClick={() => {
|
|
setDetailTaskId(task.id);
|
|
setShowDetailModal(true);
|
|
}}
|
|
className="min-w-0 flex-1 cursor-pointer space-y-1"
|
|
>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<p
|
|
className={`text-sm break-words leading-tight ${
|
|
isCompleted ? 'line-through text-faint' : 'text-main font-medium'
|
|
}`}
|
|
>
|
|
{task.content}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Subtasks Progress Bar */}
|
|
{renderTaskProgressBar(task)}
|
|
|
|
{/* Interactive Badges Bar */}
|
|
<div
|
|
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Kanban status selector pill */}
|
|
<select
|
|
value={task.kanbanStatus || (isCompleted ? 'done' : 'todo')}
|
|
onChange={(e) => handleSetKanbanStatus(task, e.target.value)}
|
|
className="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"
|
|
onClick={() => handleCyclePriority(task)}
|
|
className="badge-accent text-[10px] cursor-pointer hover:opacity-85"
|
|
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
|
|
>
|
|
🔥 Dringend
|
|
</button>
|
|
)}
|
|
{task.priority === 'high' && (
|
|
<button
|
|
type="button"
|
|
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"
|
|
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
|
|
>
|
|
⚠️ Wichtig
|
|
</button>
|
|
)}
|
|
|
|
{/* Note indicator */}
|
|
{task.notes && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDetailTaskId(task.id);
|
|
setShowDetailModal(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-[10px] text-muted hover:text-main bg-canvas px-1.5 py-0.5 rounded border border-border"
|
|
title="Notiz anzeigen / bearbeiten"
|
|
>
|
|
<FileText className="w-2.5 h-2.5 text-accent" />
|
|
<span className="truncate max-w-[120px]">{task.notes}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Due Date */}
|
|
{task.dueDate && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
|
|
<Calendar className="w-2.5 h-2.5 text-accent" />
|
|
<span>{task.dueDate}</span>
|
|
</span>
|
|
)}
|
|
|
|
{/* Hashtag badges */}
|
|
{task.tags?.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
|
className={`text-[10px] px-1.5 py-0.2 rounded-pill border transition-colors ${
|
|
selectedTag === tag
|
|
? 'bg-accent/15 border-accent text-accent font-semibold'
|
|
: 'badge-neutral hover:border-accent'
|
|
}`}
|
|
title={`Nach #${tag} filtern`}
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
|
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
draggable={false}
|
|
onClick={() => {
|
|
setDetailTaskId(task.id);
|
|
setShowDetailModal(true);
|
|
}}
|
|
className="text-faint hover:text-accent p-1.5 transition-colors"
|
|
title="Aufgabe bearbeiten"
|
|
>
|
|
<Edit3 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<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">
|
|
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
|
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
|
|
<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>
|
|
|
|
{/* 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">
|
|
{/* Task Lists selector tabs */}
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<button
|
|
onClick={() => setSelectedListId('all')}
|
|
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors ${
|
|
selectedListId === 'all'
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
Alle Aufgaben
|
|
</button>
|
|
|
|
{safeTaskLists.map((tl) => (
|
|
<div key={tl.id} className="relative group flex items-center">
|
|
<button
|
|
onClick={() => setSelectedListId(tl.id)}
|
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
selectedListId === tl.id
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<span>{tl.icon}</span>
|
|
<span>{tl.title}</span>
|
|
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
|
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
|
)}
|
|
{(() => {
|
|
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
|
if (!isShared) return null;
|
|
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
|
if (otherMembers.length > 0) {
|
|
return (
|
|
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
|
{otherMembers.slice(0, 2).map((m) => (
|
|
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
|
|
{getInitials(m.displayName || m.email)[0]}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<span title="Geteilt mit Team oder Benutzern">
|
|
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
|
|
</span>
|
|
);
|
|
})()}
|
|
</button>
|
|
{selectedListId === tl.id && (
|
|
<button
|
|
onClick={() => handleDeleteTaskList(tl.id)}
|
|
title="Liste löschen"
|
|
className="h-8 px-1.5 bg-panel border-y border-r border-accent text-faint hover:text-status-err"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
onClick={() => setShowNewListModal(true)}
|
|
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
|
|
title="Neue Aufgabenliste anlegen"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
<span>Neue Liste</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
|
|
<div className="flex items-center gap-2">
|
|
{currentSelectedList && (() => {
|
|
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
|
|
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
|
|
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
|
? currentSelectedList.shares.length
|
|
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
|
|
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
|
|
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
|
|
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
{isShared && (
|
|
isOwner ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
|
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
|
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
|
>
|
|
<Users className="w-2.5 h-2.5" />
|
|
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
|
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
|
title="Teilen & Berechtigungen öffnen"
|
|
>
|
|
<Users className="w-2.5 h-2.5" />
|
|
<span>Geteilt von {ownerDisplayName}</span>
|
|
</button>
|
|
)
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
|
className="btn-icon"
|
|
title="Aufgabenliste teilen und Berechtigungen"
|
|
>
|
|
<Users className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* View Switcher: List vs Kanban */}
|
|
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
|
<button
|
|
onClick={() => setViewMode('list')}
|
|
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors border-r border-border ${
|
|
viewMode === 'list' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
|
|
}`}
|
|
title="Listenansicht"
|
|
>
|
|
<LayoutList className="w-3.5 h-3.5" />
|
|
<span className="hidden sm:inline">Liste</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('kanban')}
|
|
className={`h-8 px-2.5 flex items-center gap-1.5 text-xs transition-colors ${
|
|
viewMode === 'kanban'
|
|
? 'bg-panel text-main font-medium'
|
|
: 'text-muted hover:text-main'
|
|
}`}
|
|
title="Kanban-Board"
|
|
>
|
|
<Columns className="w-3.5 h-3.5" />
|
|
<span className="hidden sm:inline">Kanban</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Add Task Input */}
|
|
<form
|
|
onSubmit={handleQuickAddTask}
|
|
className="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>
|
|
|
|
{/* Hashtag Filter & Grouping / Sorting Toolbar */}
|
|
<div className="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' ? (
|
|
<button
|
|
onClick={() => setShowManageColumnsModal(true)}
|
|
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
|
|
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
|
|
>
|
|
<Columns className="w-3.5 h-3.5 text-accent" />
|
|
<span>Spalten anpassen</span>
|
|
</button>
|
|
) : (
|
|
<>
|
|
{/* Group By */}
|
|
<div className="flex items-center gap-1 text-xs text-muted">
|
|
<span className="text-[11px]">Gruppe:</span>
|
|
<select
|
|
value={groupBy}
|
|
onChange={(e) => setGroupBy(e.target.value as any)}
|
|
className="h-7 px-2 input-field text-xs bg-panel"
|
|
>
|
|
<option value="none">Keine</option>
|
|
<option value="tag">Nach Hashtag</option>
|
|
<option value="status">Nach Status</option>
|
|
<option value="priority">Nach Priorität</option>
|
|
<option value="due">Nach Fälligkeit</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Sort By */}
|
|
<div className="flex items-center gap-1 text-xs text-muted">
|
|
<span className="text-[11px]">Sortierung:</span>
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value as any)}
|
|
className="h-7 px-2 input-field text-xs bg-panel"
|
|
>
|
|
<option value="custom">Manuell (Drag & Drop)</option>
|
|
<option value="created">Erstelldatum</option>
|
|
<option value="due">Fälligkeitsdatum</option>
|
|
<option value="priority">Priorität</option>
|
|
<option value="alpha">Titel (A-Z)</option>
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* NEW LIST MODAL */}
|
|
{showNewListModal && (
|
|
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
|
<div className="flex items-center justify-between border-b border-border pb-2">
|
|
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
|
<Layers className="w-4 h-4 text-accent" />
|
|
<span>Neue Aufgabenliste erstellen</span>
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowNewListModal(false)}
|
|
className="text-muted hover:text-main"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleCreateTaskList} className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={newListIcon}
|
|
onChange={(e) => setNewListIcon(e.target.value)}
|
|
maxLength={2}
|
|
className="w-10 h-8 text-center input-field text-sm"
|
|
title="Icon (Emoji)"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={newListTitle}
|
|
onChange={(e) => setNewListTitle(e.target.value)}
|
|
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
|
autoFocus
|
|
required
|
|
className="flex-1 h-8 input-field px-2.5 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNewListModal(false)}
|
|
className="btn-action h-7 px-3 text-xs"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
|
Erstellen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* UNIFIED SHARE LIST MODAL */}
|
|
{showShareListModal && sharingTaskList && (
|
|
<UnifiedShareModal
|
|
isOpen={showShareListModal}
|
|
onClose={() => {
|
|
setShowShareListModal(false);
|
|
setSharingTaskList(null);
|
|
}}
|
|
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
|
|
resourceName={sharingTaskList.title}
|
|
resourceIcon={sharingTaskList.icon || '📋'}
|
|
isOwner={sharingTaskList.isOwner !== false}
|
|
currentUserId={currentUser?.id}
|
|
shares={(sharingTaskList.shares || []).map((sh) => ({
|
|
id: sh.id,
|
|
targetType: sh.targetType,
|
|
targetId: sh.targetId,
|
|
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
|
targetEmail: sh.email,
|
|
permission: 'editor' as const,
|
|
role: 'editor',
|
|
createdAt: sh.createdAt,
|
|
}))}
|
|
teams={safeTeams}
|
|
collaborators={safeCollaborators}
|
|
allowRoleSelection={false}
|
|
onAddShare={async (targetType, targetId) => {
|
|
await api.addTaskListShare(sharingTaskList.id, {
|
|
targetType,
|
|
targetId,
|
|
});
|
|
const [freshLists, freshShares] = await Promise.all([
|
|
api.getTaskLists(),
|
|
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
|
]);
|
|
setTaskLists(freshLists);
|
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
|
if (updated) {
|
|
setSharingTaskList({ ...updated, shares: freshShares });
|
|
}
|
|
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}
|
|
onUpdateTitle={handleUpdateTaskTitle}
|
|
onUpdateStatus={handleSetKanbanStatus}
|
|
onUpdatePriority={handleSetPriority}
|
|
onUpdateDueDate={handleUpdateTaskDueDate}
|
|
onUpdateTaskList={handleUpdateTaskTaskList}
|
|
onUpdateNotes={handleUpdateTaskNotes}
|
|
onUpdateTags={handleUpdateTaskTags}
|
|
onToggleSubtask={handleToggleSubtask}
|
|
onAddSubtask={handleAddSubtask}
|
|
onDeleteSubtask={handleDeleteSubtask}
|
|
onConvertToDoc={handleConvertToDoc}
|
|
onDeleteTask={handleDeletePermanent}
|
|
/>
|
|
|
|
{/* VIEW MODES: LIST OR KANBAN */}
|
|
{viewMode === 'kanban' ? (
|
|
/* DYNAMIC KANBAN BOARD */
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 pt-1">
|
|
{activeColumns.map((col, colIdx) => {
|
|
const colTasks = kanbanTasks.filter((t) => {
|
|
if (col.isDone) {
|
|
return t.isCompleted || t.kanbanStatus === col.id;
|
|
}
|
|
if (t.isCompleted) return false;
|
|
if (t.kanbanStatus) return t.kanbanStatus === col.id;
|
|
return col.id === 'todo';
|
|
});
|
|
|
|
return (
|
|
<div
|
|
key={col.id}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
if (draggingTaskId) setDragOverKanbanCol(col.id);
|
|
}}
|
|
onDragLeave={() => {
|
|
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
|
}}
|
|
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
|
className={`bg-canvas border border-border flex flex-col min-h-[420px] transition-all ${
|
|
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
|
}`}
|
|
>
|
|
{/* Column Header */}
|
|
<div className="px-3 py-2 bg-panel border-b border-border flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
col.isDone
|
|
? 'bg-status-ok'
|
|
: col.id === 'in_progress'
|
|
? 'bg-accent'
|
|
: col.id === 'waiting'
|
|
? 'bg-status-warn'
|
|
: 'bg-faint'
|
|
}`}
|
|
/>
|
|
<span>{col.name}</span>
|
|
</span>
|
|
<span className="badge-neutral font-mono">{colTasks.length}</span>
|
|
</div>
|
|
|
|
{/* Tasks List in Column */}
|
|
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
|
{colTasks.map((task) => {
|
|
const isDragging = draggingTaskId === task.id;
|
|
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
|
|
|
return (
|
|
<div
|
|
key={task.id}
|
|
draggable
|
|
onDragStart={(e) => handleTaskDragStart(e, task)}
|
|
onDragEnd={() => {
|
|
setDraggingTaskId(null);
|
|
setDragOverTaskTarget(null);
|
|
setDragOverKanbanCol(null);
|
|
}}
|
|
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
|
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
|
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
|
onClick={() => {
|
|
setDetailTaskId(task.id);
|
|
setShowDetailModal(true);
|
|
}}
|
|
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all cursor-grab active:cursor-grabbing shadow-sm group relative ${
|
|
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>
|
|
<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: Priority, Status, Tags */}
|
|
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
|
{task.priority === 'urgent' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCyclePriority(task)}
|
|
className="badge-accent text-[9px] cursor-pointer hover:opacity-85"
|
|
title="Klicken zum Ändern der Priorität (Dringend -> Normal)"
|
|
>
|
|
🔥 Dringend
|
|
</button>
|
|
)}
|
|
{task.priority === 'high' && (
|
|
<button
|
|
type="button"
|
|
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"
|
|
title="Klicken zum Ändern der Priorität (Wichtig -> Dringend)"
|
|
>
|
|
⚠️ Wichtig
|
|
</button>
|
|
)}
|
|
{task.dueDate && (
|
|
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
|
|
<Calendar className="w-2.5 h-2.5" />
|
|
<span>{task.dueDate}</span>
|
|
</span>
|
|
)}
|
|
{task.tags?.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
type="button"
|
|
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
|
className={`text-[9px] px-1.5 py-0.2 rounded-pill border transition-colors ${
|
|
selectedTag === tag
|
|
? 'bg-accent/15 border-accent text-accent font-semibold'
|
|
: 'badge-neutral hover:border-accent'
|
|
}`}
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Column Navigation controls: Move Left / Move Right */}
|
|
<div
|
|
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={colIdx === 0}
|
|
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx - 1].id)}
|
|
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
|
|
title={colIdx > 0 ? `Nach "${activeColumns[colIdx - 1].name}"` : ''}
|
|
>
|
|
<ArrowLeft className="w-3 h-3" />
|
|
<span className="hidden sm:inline">{colIdx > 0 ? activeColumns[colIdx - 1].name : ''}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={colIdx === activeColumns.length - 1}
|
|
onClick={() => handleSetKanbanStatus(task, activeColumns[colIdx + 1].id)}
|
|
className="text-faint hover:text-main disabled:opacity-20 flex items-center gap-0.5"
|
|
title={colIdx < activeColumns.length - 1 ? `Nach "${activeColumns[colIdx + 1].name}"` : ''}
|
|
>
|
|
<span className="hidden sm:inline">{colIdx < activeColumns.length - 1 ? activeColumns[colIdx + 1].name : ''}</span>
|
|
<ArrowRight className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{colTasks.length === 0 && (
|
|
<div className="p-4 text-center text-faint text-xs italic">
|
|
Keine Aufgaben in dieser Spalte
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick Add Task to Column */}
|
|
<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>
|
|
);
|
|
};
|