From b5df4a34b478b82960537f6ca286d7b541545eca Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 13:13:11 +0100 Subject: [PATCH] feat(tasks): customizable kanban columns, task detail page, hashtag filtering and clean task list --- .../features/triage/ManageColumnsModal.tsx | 295 ++++ .../src/features/triage/TaskDetailModal.tsx | 665 ++++++++ frontend/src/features/triage/TasksView.tsx | 1451 +++++++++-------- frontend/src/lib/api.ts | 9 +- frontend/src/lib/types.ts | 10 +- internal/api/server.go | 16 +- internal/domain/models.go | 8 + internal/storage/sqlite.go | 82 +- 8 files changed, 1847 insertions(+), 689 deletions(-) create mode 100644 frontend/src/features/triage/ManageColumnsModal.tsx create mode 100644 frontend/src/features/triage/TaskDetailModal.tsx diff --git a/frontend/src/features/triage/ManageColumnsModal.tsx b/frontend/src/features/triage/ManageColumnsModal.tsx new file mode 100644 index 0000000..c437605 --- /dev/null +++ b/frontend/src/features/triage/ManageColumnsModal.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + Plus, + Trash2, + ArrowUp, + ArrowDown, + RotateCcw, + Check, + Columns, + Layers, +} from 'lucide-react'; +import { KanbanColumn } from '../../lib/types'; +import { triggerHaptic } from '../../lib/api'; + +interface ManageColumnsModalProps { + isOpen: boolean; + onClose: () => void; + initialColumns: KanbanColumn[]; + onSave: (columns: KanbanColumn[]) => Promise; + listTitle?: string; +} + +export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [ + { id: 'todo', name: 'Zu erledigen' }, + { id: 'in_progress', name: 'In Bearbeitung', color: 'accent' }, + { id: 'waiting', name: 'Wartet auf andere', color: 'amber' }, + { id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' }, +]; + +export const ManageColumnsModal: React.FC = ({ + isOpen, + onClose, + initialColumns, + onSave, + listTitle, +}) => { + if (!isOpen) return null; + + const [columns, setColumns] = useState(() => { + return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS; + }); + const [newColName, setNewColName] = useState(''); + const [newColIsDone, setNewColIsDone] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS); + }, [initialColumns, isOpen]); + + const handleMove = (index: number, direction: 'up' | 'down') => { + triggerHaptic(10); + const targetIndex = direction === 'up' ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= columns.length) return; + const next = [...columns]; + const temp = next[index]; + next[index] = next[targetIndex]; + next[targetIndex] = temp; + setColumns(next); + }; + + const handleRename = (id: string, newName: string) => { + setColumns((prev) => + prev.map((c) => (c.id === id ? { ...c, name: newName } : c)) + ); + }; + + const handleToggleDone = (id: string) => { + triggerHaptic(10); + setColumns((prev) => + prev.map((c) => (c.id === id ? { ...c, isDone: !c.isDone } : c)) + ); + }; + + const handleDelete = (id: string) => { + if (columns.length <= 1) { + alert('Mindestens eine Spalte muss vorhanden sein.'); + return; + } + triggerHaptic(10); + setColumns((prev) => prev.filter((c) => c.id !== id)); + }; + + const handleAddColumn = (e: React.FormEvent) => { + e.preventDefault(); + const name = newColName.trim(); + if (!name) return; + triggerHaptic(10); + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').slice(0, 20) || 'col'; + const id = `${slug}_${Date.now().toString(36).slice(-4)}`; + setColumns((prev) => [ + ...prev, + { + id, + name, + isDone: newColIsDone, + }, + ]); + setNewColName(''); + setNewColIsDone(false); + }; + + const handleResetToDefault = () => { + if (window.confirm('Spalten auf Standard zurücksetzen? (Zu erledigen, In Bearbeitung, Wartet auf andere, Erledigt)')) { + triggerHaptic(15); + setColumns(DEFAULT_KANBAN_COLUMNS); + } + }; + + const handleSave = async () => { + if (columns.length === 0) { + alert('Es muss mindestens eine Spalte vorhanden sein.'); + return; + } + setSaving(true); + triggerHaptic(15); + try { + await onSave(columns); + onClose(); + } catch (err: any) { + alert(err.message || 'Fehler beim Speichern der Spalten'); + } finally { + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +
+

Kanban-Spalten anpassen

+ {listTitle && ( +

Liste: {listTitle}

+ )} +
+
+ +
+ + {/* Content */} +
+

+ Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden. +

+ + {/* Columns List */} +
+ +
+ {columns.map((col, idx) => ( +
+
+ + +
+ + handleRename(col.id, e.target.value)} + className="flex-1 h-7 px-2 text-xs input-field" + placeholder="Spaltenname" + /> + + {/* IsDone Toggle */} + + + +
+ ))} +
+
+ + {/* Add New Column Form */} +
+ +
+ setNewColName(e.target.value)} + placeholder="z.B. Wartet auf Feedback, Review…" + className="flex-1 h-8 px-2.5 text-xs input-field" + /> + + +
+
+ + {/* Reset button */} +
+ +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx new file mode 100644 index 0000000..4f138c6 --- /dev/null +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -0,0 +1,665 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + Check, + Calendar, + Trash2, + FileText, + Plus, + CheckSquare, + Clock, + Layers, + Hash, + AlertTriangle, + Flame, + ChevronRight, + Folder, +} from 'lucide-react'; +import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; +import { triggerHaptic } from '../../lib/api'; + +interface TaskDetailModalProps { + task: Thought | null; + isOpen: boolean; + onClose: () => void; + activeColumns: KanbanColumn[]; + taskLists: TaskList[]; + onUpdateTitle: (task: Thought, newTitle: string) => Promise; + onUpdateStatus: (task: Thought, newStatus: string) => Promise; + onUpdatePriority: (task: Thought, newPriority: string) => Promise; + onUpdateDueDate: (task: Thought, newDueDate: string) => Promise; + onUpdateTaskList: (task: Thought, newListId: string) => Promise; + onUpdateNotes: (task: Thought, newNotes: string) => Promise; + onUpdateTags?: (task: Thought, newTags: string[]) => Promise; + onToggleSubtask: (task: Thought, subtaskId: string) => Promise; + onAddSubtask: (task: Thought, title: string) => Promise; + onDeleteSubtask: (task: Thought, subtaskId: string) => Promise; + onConvertToDoc: (task: Thought) => void; + onDeleteTask: (taskId: string) => void; +} + +export const TaskDetailModal: React.FC = ({ + task, + isOpen, + onClose, + activeColumns, + taskLists, + onUpdateTitle, + onUpdateStatus, + onUpdatePriority, + onUpdateDueDate, + onUpdateTaskList, + onUpdateNotes, + onUpdateTags, + onToggleSubtask, + onAddSubtask, + onDeleteSubtask, + onConvertToDoc, + onDeleteTask, +}) => { + if (!isOpen || !task) return null; + + const [titleDraft, setTitleDraft] = useState(task.content); + const [isEditingTitle, setIsEditingTitle] = useState(false); + + const [isEditingNotes, setIsEditingNotes] = useState(false); + const [notesDraft, setNotesDraft] = useState(task.notes || ''); + + const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); + const [newTagDraft, setNewTagDraft] = useState(''); + + useEffect(() => { + setTitleDraft(task.content); + setNotesDraft(task.notes || ''); + setIsEditingTitle(false); + setIsEditingNotes(false); + }, [task.id]); + + // Handle escape key to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (isEditingNotes) { + setIsEditingNotes(false); + } else if (isEditingTitle) { + setIsEditingTitle(false); + } else { + onClose(); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, isEditingNotes, isEditingTitle]); + + const handleTitleSubmit = async () => { + const trimmed = titleDraft.trim(); + if (!trimmed) { + setTitleDraft(task.content); + setIsEditingTitle(false); + return; + } + if (trimmed !== task.content) { + await onUpdateTitle(task, trimmed); + } + setIsEditingTitle(false); + }; + + const handleNotesSubmit = async () => { + await onUpdateNotes(task, notesDraft); + setIsEditingNotes(false); + }; + + const handleAddSubtaskSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newSubtaskTitle.trim(); + if (!trimmed) return; + await onAddSubtask(task, trimmed); + setNewSubtaskTitle(''); + }; + + const handleAddTagSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase(); + if (!cleaned) return; + const currentTags = Array.isArray(task.tags) ? [...task.tags] : []; + if (!currentTags.includes(cleaned)) { + const updated = [...currentTags, cleaned]; + if (onUpdateTags) { + await onUpdateTags(task, updated); + } else { + await onUpdateTitle(task, `${task.content} #${cleaned}`); + } + } + setNewTagDraft(''); + }; + + const handleRemoveTag = async (tagToRemove: string) => { + const currentTags = Array.isArray(task.tags) ? [...task.tags] : []; + const updated = currentTags.filter((t) => t !== tagToRemove); + if (onUpdateTags) { + await onUpdateTags(task, updated); + } + }; + + const handleQuickDueDate = (daysOffset: number | null) => { + if (daysOffset === null) { + onUpdateDueDate(task, ''); + return; + } + const d = new Date(); + d.setDate(d.getDate() + daysOffset); + const dateStr = d.toISOString().split('T')[0]; + onUpdateDueDate(task, dateStr); + }; + + const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) || + (task.isCompleted + ? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1] + : activeColumns.find((c) => !c.isDone) || activeColumns[0]); + + const subtasks = Array.isArray(task.subtasks) ? task.subtasks : []; + const completedSubtasks = subtasks.filter((s) => s.isCompleted).length; + const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0; + + return ( +
+
e.stopPropagation()} + > + {/* Top Header */} +
+
+ + + {task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'} + +
+ +
+ + + +
+
+ + {/* Scrollable Body */} +
+ {/* Task Title */} +
+ {isEditingTitle ? ( +
+