Files
MindSift/frontend/src/features/triage/TaskDetailModal.tsx
T
sandrodachandClaude Opus 5.5 4c50d15915 chore(frontend): real ESLint instead of a placeholder; fix hook order (FLAW-19)
eslint.config.js with the recommended JS/TypeScript rules and the React
hooks rules; npm run lint runs ESLint and is part of the image build.
Errors fail the build, the 24 exhaustive-deps findings stay warnings.

It found two real bugs: TaskDetailModal and ManageColumnsModal called
their hooks after 'if (!isOpen) return null', so the hook count changed
whenever the dialog opened or closed. Both are now a thin wrapper plus a
content component. Also removes unused imports and dead code (old task
list share handlers, an unused realtime status, an unused rollup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:02:52 +01:00

869 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react';
import {
X,
Check,
Calendar,
Trash2,
FileText,
Plus,
CheckSquare,
Hash,
AlertTriangle,
Flame,
Folder,
Webhook,
Lock,
UserPlus,
Repeat,
} from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due';
interface TaskDetailModalProps {
task: Thought | null;
isOpen: boolean;
onClose: () => void;
activeColumns: KanbanColumn[];
taskLists: TaskList[];
onUpdateTitle: (task: Thought, newTitle: string) => Promise<void>;
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
assignableMembers?: TaskListMember[]; // empty unless the task's list is shared
onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise<void>;
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onAddSubtask: (task: Thought, title: string) => Promise<void>;
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
onConvertToDoc: (task: Thought) => void;
onOpenDocument?: (docId: string, blockId?: string) => void;
onDeleteTask: (taskId: string) => void;
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
}
// The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never
// changes between renders (hooks after an early return break React when isOpen flips)
export const TaskDetailModal: React.FC<TaskDetailModalProps> = (props) =>
props.isOpen && props.task ? <TaskDetailModalContent {...props} task={props.task} /> : null;
const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }> = ({
task,
onClose,
activeColumns,
taskLists,
onUpdateTitle,
onUpdateStatus,
onUpdatePriority,
onUpdateDueDate,
onUpdateRecurrence,
onUpdateTaskList,
onUpdateNotes,
onUpdateTags,
assignableMembers = [],
onUpdateAssignees,
onToggleSubtask,
onAddSubtask,
onDeleteSubtask,
onConvertToDoc,
onOpenDocument,
onDeleteTask,
readOnly = false,
}) => {
const [titleDraft, setTitleDraft] = useState(task.content);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [isEditingNotes, setIsEditingNotes] = useState(false);
const [notesDraft, setNotesDraft] = useState(task.notes || '');
// The notes field grows with its text, so long notes scroll with the dialog instead of inside a small box
const growNotes = useCallback((el: HTMLTextAreaElement | null) => {
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight + 2}px`;
}, []);
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();
const 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 dueParts = splitDue(task.dueDate);
const handleQuickDueDate = (daysOffset: number | null) => {
if (daysOffset === null) {
onUpdateDueDate(task, '');
return;
}
// Keep a chosen time when only the day changes
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
};
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 (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
<div
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Top Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2 min-w-0">
<button
disabled={readOnly}
onClick={() => {
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');
onUpdateStatus(task, nextKanban);
}}
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
task.isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
>
<Check className="w-3.5 h-3.5 stroke-[3]" />
</button>
<span className="text-xs font-semibold text-main truncate">
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
</span>
{readOnly && (
<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">
<Lock className="w-2.5 h-2.5 text-faint" />
<span>Nur Lesen</span>
</span>
)}
{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">
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{(task.convertedType === 'document_block' || task.docId) ? (
<button
type="button"
onClick={() => {
onClose();
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
}}
title="Zur verlinkten Notiz springen"
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span>
</button>
) : !readOnly && (
<button
type="button"
onClick={() => {
onClose();
onConvertToDoc(task);
}}
title="Als Dokument / Notiz vertiefen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span className="hidden sm:inline">Als Notiz vertiefen</span>
</button>
)}
{!readOnly && (
<button
type="button"
onClick={() => {
if (window.confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
onClose();
onDeleteTask(task.id);
}
}}
title="Aufgabe löschen"
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs text-status-err border-status-err/30 hover:bg-status-err/10 flex items-center gap-1"
>
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span className="sm:hidden font-medium">Löschen</span>
</button>
)}
<button
type="button"
onClick={onClose}
className="text-muted hover:text-main p-2 sm:p-1.5 min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 flex items-center justify-center transition-colors"
title="Schließen"
>
<X className="w-5 h-5 sm:w-4 sm:h-4" />
</button>
</div>
</div>
{/* Scrollable Body (all controls disabled for read-only shares) */}
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
{/* Task Title */}
<div>
{isEditingTitle ? (
<div className="space-y-2">
<textarea
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleTitleSubmit();
} else if (e.key === 'Escape') {
setIsEditingTitle(false);
setTitleDraft(task.content);
}
}}
autoFocus
rows={2}
className="w-full text-base font-semibold input-field p-2.5 resize-none leading-snug"
/>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => {
setIsEditingTitle(false);
setTitleDraft(task.content);
}}
className="btn-action h-7 px-2.5 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleTitleSubmit}
className="btn-primary h-7 px-3 text-xs"
>
Speichern
</button>
</div>
</div>
) : (
<div
onClick={() => !readOnly && setIsEditingTitle(true)}
className={`group p-2 rounded-card transition-colors border border-transparent ${
readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
>
<h2
className={`text-base font-semibold break-words leading-snug ${
task.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{task.content}
</h2>
{!readOnly && (
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
Klicken zum Umbenennen
</span>
)}
</div>
)}
</div>
{/* Kanban Status Buttons */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main">
Zustand / Kanban-Status:
</label>
<div className="flex flex-wrap items-center gap-1.5">
{activeColumns.map((col) => {
const isActive = (task.kanbanStatus === col.id) || (!task.kanbanStatus && col.id === 'todo');
return (
<button
key={col.id}
type="button"
onClick={() => {
triggerHaptic(10);
onUpdateStatus(task, col.id);
}}
className={`h-8 px-3 text-xs rounded-btn font-medium transition-all flex items-center gap-1.5 border ${
isActive
? 'bg-panel border-accent text-accent font-semibold shadow-sm'
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
}`}
>
<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>
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
</button>
);
})}
</div>
</div>
{/* Priority Buttons (4 Buttons) */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main">
Priorität:
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'low');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
task.priority === 'low'
? 'bg-canvas border-strong text-main font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>Niedrig</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'normal');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
!task.priority || task.priority === 'normal'
? 'bg-accent/15 border-accent text-accent font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>Normal</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'high');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
task.priority === 'high'
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<AlertTriangle className="w-3 h-3 text-status-warn" />
<span>Wichtig</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
onUpdatePriority(task, 'urgent');
}}
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
task.priority === 'urgent'
? 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Flame className="w-3 h-3 text-status-err" />
<span>Dringend</span>
</button>
</div>
</div>
{/* Due Date & List Selection Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
{/* Due Date */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Fällig:</span>
</label>
<div className="flex items-center gap-1.5">
<input
type="date"
aria-label="Fälligkeitsdatum"
value={dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
/>
<input
type="time"
aria-label="Uhrzeit (optional)"
title="Uhrzeit (optional) – ohne Uhrzeit gilt die Aufgabe den ganzen Tag"
value={dueParts.time}
disabled={!dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
/>
{task.dueDate && (
<button
type="button"
onClick={() => onUpdateDueDate(task, '')}
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center text-muted hover:text-status-err shrink-0"
title="Fälligkeit entfernen"
aria-label="Fälligkeit entfernen"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="flex flex-wrap gap-1 text-[10px]">
<button
type="button"
onClick={() => handleQuickDueDate(0)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
>
Heute
</button>
<button
type="button"
onClick={() => handleQuickDueDate(1)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
>
Morgen
</button>
<button
type="button"
onClick={() => handleQuickDueDate(7)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
>
+1 Woche
</button>
{dueParts.time && (
<button
type="button"
onClick={() => onUpdateDueDate(task, dueParts.date)}
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main inline-flex items-center gap-1"
title="Uhrzeit entfernen – die Aufgabe gilt dann den ganzen Tag"
>
<X className="w-2.5 h-2.5" />
Uhrzeit
</button>
)}
</div>
{onUpdateRecurrence && (
<div className="pt-1">
<label className="flex items-center gap-1 text-[11px] text-muted mb-1">
<Repeat className="w-3 h-3 text-accent" />
<span>Wiederholung</span>
</label>
<select
value={task.recurrence || ''}
disabled={readOnly}
onChange={(e) => onUpdateRecurrence(task, e.target.value as Recurrence)}
className="w-full h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
>
<option value="">Keine Wiederholung</option>
<option value="daily">🔁 Täglich</option>
<option value="weekly">🔁 Wöchentlich</option>
<option value="monthly">🔁 Monatlich</option>
<option value="yearly">🔁 Jährlich</option>
</select>
{task.recurrence && (
<p className="text-[10px] text-faint mt-1">
Beim Erledigen entsteht automatisch die nächste Aufgabe, fällig{' '}
{task.dueDate ? 'ein Intervall nach diesem Datum' : 'ein Intervall nach dem Erledigen'}.
</p>
)}
</div>
)}
</div>
{/* Task List assignment */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Folder className="w-3.5 h-3.5 text-accent" />
<span>Aufgabenliste:</span>
</label>
<select
value={task.taskListId || ''}
onChange={(e) => onUpdateTaskList(task, e.target.value)}
className="w-full h-8 input-field px-2.5 text-xs bg-panel"
>
<option value="">-- Keine Liste (Allgemein) --</option>
{taskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{tl.icon} {tl.title}
</option>
))}
</select>
</div>
</div>
{/* Assignees (shared lists only): chips + the shared type-to-search */}
{assignableMembers.length > 0 && (() => {
const assigned = (task.assignees || [])
.map((id) => assignableMembers.find((m) => m.userId === id))
.filter((m): m is TaskListMember => !!m);
const assignedIds = assigned.map((m) => m.userId);
const candidates: User[] = assignableMembers.map((m) => ({
id: m.userId,
email: m.email,
displayName: m.displayName,
avatarUrl: m.avatarUrl,
role: 'member',
isActive: true,
createdAt: '',
}));
return (
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<UserPlus className="w-3.5 h-3.5 text-accent" />
<span>Zugewiesen:</span>
</label>
{assigned.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
{assigned.map((m) => (
<span
key={m.userId}
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
>
<UserAvatar name={m.displayName || m.email} avatarUrl={m.avatarUrl || ''} size="xxs" showTitle={false} />
<span>{m.displayName || m.email}</span>
{!readOnly && onUpdateAssignees && (
<button
type="button"
onClick={() => onUpdateAssignees(task, assignedIds.filter((id) => id !== m.userId))}
className="text-muted hover:text-status-err"
title="Zuweisung entfernen"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
) : (
readOnly && <p className="text-xs text-muted">Niemandem zugewiesen</p>
)}
{!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && (
<UserAutocomplete
users={candidates}
selectedUser={null}
onSelectUser={(u) => u && onUpdateAssignees(task, [...assignedIds, u.id])}
excludeUserIds={assignedIds}
placeholder="Person zuweisen (Name tippen)…"
/>
)}
</div>
);
})()}
{/* Hashtags Section */}
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
<Hash className="w-3.5 h-3.5 text-accent" />
<span>Hashtags:</span>
</label>
<div className="flex flex-wrap items-center gap-1.5">
{Array.isArray(task.tags) &&
task.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
>
<span>#{tag}</span>
{!readOnly && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-muted hover:text-status-err"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{!readOnly && (
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
<input
type="text"
value={newTagDraft}
onChange={(e) => setNewTagDraft(e.target.value)}
placeholder="+ #hashtag"
className="h-6 w-24 px-1.5 text-xs input-field"
/>
</form>
)}
</div>
</div>
{/* Subtasks Checklist */}
<div className="space-y-2 pt-1 border-t border-border">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>Unteraufgaben ({completedSubtasks}/{subtasks.length}):</span>
</label>
{subtasks.length > 0 && (
<span className="text-[11px] font-mono text-muted">{subtasksPercent}%</span>
)}
</div>
{subtasks.length > 0 && (
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
subtasksPercent === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${subtasksPercent}%` }}
/>
</div>
)}
{/* Subtask items list */}
{subtasks.length > 0 && (
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{subtasks.map((st) => (
<div
key={st.id}
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs group"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={() => onToggleSubtask(task, st.id)}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
st.isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
className={`truncate ${
st.isCompleted ? 'line-through text-faint' : 'text-main'
}`}
>
{st.title}
</span>
</div>
{!readOnly && (
<button
type="button"
onClick={() => onDeleteSubtask(task, st.id)}
className="opacity-0 group-hover:opacity-100 text-faint hover:text-status-err p-0.5 transition-opacity"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Inline add subtask form */}
{!readOnly && (
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
<input
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Neue Unteraufgabe hinzufügen…"
className="flex-1 h-7 px-2 text-xs input-field"
/>
<button
type="submit"
disabled={!newSubtaskTitle.trim()}
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40"
>
<Plus className="w-3 h-3" />
<span>Hinzufügen</span>
</button>
</form>
)}
</div>
{/* Notes Section */}
<div className="space-y-2 pt-1 border-t border-border">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Detailnotizen:</span>
</label>
{!readOnly && !isEditingNotes && task.notes && (
<button
type="button"
onClick={() => {
setNotesDraft(task.notes || '');
setIsEditingNotes(true);
}}
className="text-[11px] text-accent hover:underline"
>
Bearbeiten
</button>
)}
</div>
{isEditingNotes ? (
<div className="space-y-2">
<textarea
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
rows={6}
autoFocus
ref={growNotes}
onInput={(e) => growNotes(e.currentTarget)}
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] overflow-hidden"
/>
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => {
setIsEditingNotes(false);
setNotesDraft(task.notes || '');
}}
className="btn-action h-7 px-2.5 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleNotesSubmit}
className="btn-primary h-7 px-3 text-xs"
>
Notiz speichern
</button>
</div>
</div>
) : task.notes ? (
<div
onClick={() => {
if (readOnly) return;
setNotesDraft(task.notes || '');
setIsEditingNotes(true);
}}
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
readOnly ? '' : 'cursor-pointer hover:border-strong'
}`}
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
>
<MarkdownViewer content={task.notes} />
</div>
) : readOnly ? (
<p className="text-xs text-faint">Keine Detailnotizen.</p>
) : (
<button
type="button"
onClick={() => {
setNotesDraft('');
setIsEditingNotes(true);
}}
className="w-full py-2.5 px-3 border border-dashed border-border rounded-card text-xs text-muted hover:text-main hover:bg-panel transition-colors text-left flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5 text-accent" />
<span>Detailnotiz hinzufügen…</span>
</button>
)}
</div>
</fieldset>
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
<div className="text-[11px] text-faint">
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
</div>
<button
type="button"
onClick={onClose}
className="btn-action h-7 px-3 text-xs"
>
Fertig
</button>
</div>
</div>
</div>
);
};