Files
MindSift/frontend/src/features/triage/TaskDetailModal.tsx
T
sandrodach 212cb32bb0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
feat(mobile): add context-sensitive MindSift logo menu, slide-over file drawer, and larger touch targets
2026-10-25 21:08:48 +01:00

667 lines
26 KiB
TypeScript

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<void>;
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
onUpdateTags?: (task: Thought, newTags: 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;
onDeleteTask: (taskId: string) => void;
}
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
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 (
<div className="fixed inset-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] 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
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 ${
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">
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
</span>
</div>
<div className="flex items-center gap-1.5">
<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>
<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 */}
<div className="p-4 overflow-y-auto space-y-4 flex-1">
{/* 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={() => setIsEditingTitle(true)}
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border"
title="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>
<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 */}
<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="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => onUpdatePriority(task, 'normal')}
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border ${
!task.priority || task.priority === 'normal' || task.priority === 'low'
? 'bg-panel border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
Normal / Keine
</button>
<button
type="button"
onClick={() => onUpdatePriority(task, 'high')}
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
task.priority === 'high'
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold'
: '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={() => onUpdatePriority(task, 'urgent')}
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
task.priority === 'urgent'
? 'bg-status-err/15 border-status-err text-status-err font-semibold'
: '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älligkeitsdatum:</span>
</label>
<div className="flex items-center gap-1.5">
<input
type="date"
value={task.dueDate || ''}
onChange={(e) => onUpdateDueDate(task, e.target.value)}
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
/>
{task.dueDate && (
<button
type="button"
onClick={() => onUpdateDueDate(task, '')}
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
title="Datum 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>
</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>
{/* 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>
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="text-muted hover:text-status-err"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<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>
<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 */}
<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>
{!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
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
/>
<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={() => {
setNotesDraft(task.notes || '');
setIsEditingNotes(true);
}}
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
title="Klicken zum Bearbeiten"
>
{task.notes}
</div>
) : (
<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>
</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>
);
};