Due dates can carry a time (YYYY-MM-DDTHH:MM). Alarms fire at that time,
or 09:00 for all-day tasks, minus the user's lead time, in TZ (default
Europe/Berlin, tzdata embedded). A new due date or reopening a task
re-arms the alarm. Recurrence keeps the time; Markdown export/import
carry it. The UI shows relative labels ("Heute, 14:30"), marks overdue
tasks to the minute and groups by local day instead of the UTC date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
875 lines
36 KiB
TypeScript
875 lines
36 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import {
|
||
X,
|
||
Check,
|
||
Calendar,
|
||
Trash2,
|
||
FileText,
|
||
Plus,
|
||
CheckSquare,
|
||
Clock,
|
||
Layers,
|
||
Hash,
|
||
AlertTriangle,
|
||
Flame,
|
||
ChevronRight,
|
||
Folder,
|
||
Webhook,
|
||
Lock,
|
||
UserPlus,
|
||
Repeat,
|
||
} from 'lucide-react';
|
||
import { Thought, SubTask, 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
|
||
}
|
||
|
||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||
task,
|
||
isOpen,
|
||
onClose,
|
||
activeColumns,
|
||
taskLists,
|
||
onUpdateTitle,
|
||
onUpdateStatus,
|
||
onUpdatePriority,
|
||
onUpdateDueDate,
|
||
onUpdateRecurrence,
|
||
onUpdateTaskList,
|
||
onUpdateNotes,
|
||
onUpdateTags,
|
||
assignableMembers = [],
|
||
onUpdateAssignees,
|
||
onToggleSubtask,
|
||
onAddSubtask,
|
||
onDeleteSubtask,
|
||
onConvertToDoc,
|
||
onOpenDocument,
|
||
onDeleteTask,
|
||
readOnly = false,
|
||
}) => {
|
||
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 || '');
|
||
// 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();
|
||
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 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 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 !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>
|
||
);
|
||
};
|