feat(tasks): customizable kanban columns, task detail page, hashtag filtering and clean task list
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m0s
This commit is contained in:
1 parent
0afa2f9c2d
commit
b5df4a34b4
8 files changed
+1847
-689
No files matched your search
@@ -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<void>;
|
||||
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<ManageColumnsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialColumns,
|
||||
onSave,
|
||||
listTitle,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
||||
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 (
|
||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 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">
|
||||
<Columns className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-main">Kanban-Spalten anpassen</h3>
|
||||
{listTitle && (
|
||||
<p className="text-[10px] text-muted truncate">Liste: {listTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-muted">
|
||||
Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden.
|
||||
</p>
|
||||
|
||||
{/* Columns List */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => handleMove(idx, 'up')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach oben"
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === columns.length - 1}
|
||||
onClick={() => handleMove(idx, 'down')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach unten"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={col.name}
|
||||
onChange={(e) => handleRename(col.id, e.target.value)}
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
placeholder="Spaltenname"
|
||||
/>
|
||||
|
||||
{/* IsDone Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleDone(col.id)}
|
||||
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
col.isDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-faint hover:text-muted'
|
||||
}`}
|
||||
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
||||
>
|
||||
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(col.id)}
|
||||
disabled={columns.length <= 1}
|
||||
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
|
||||
title="Spalte löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Column Form */}
|
||||
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newColName}
|
||||
onChange={(e) => setNewColName(e.target.value)}
|
||||
placeholder="z.B. Wartet auf Feedback, Review…"
|
||||
className="flex-1 h-8 px-2.5 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewColIsDone((prev) => !prev)}
|
||||
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
newColIsDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-muted hover:text-main'
|
||||
}`}
|
||||
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newColName.trim()}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="pt-2 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetToDefault}
|
||||
className="text-xs text-muted hover:text-main flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 text-accent" />
|
||||
<span>Auf Standard-Spalten zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary h-7 px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
Spalten speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
className="btn-action h-7 px-2 text-xs flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3.5 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="text-muted hover:text-status-err p-1.5 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ import {
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
@@ -313,11 +314,12 @@ export const api = {
|
||||
content?: string;
|
||||
isCompleted?: boolean;
|
||||
dueDate?: string;
|
||||
priority?: PriorityLevel;
|
||||
priority?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -341,6 +343,7 @@ export const api = {
|
||||
...tl,
|
||||
members: ensureArray(tl.members),
|
||||
shares: ensureArray(tl.shares),
|
||||
columns: ensureArray(tl.columns),
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -350,6 +353,7 @@ export const api = {
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
columns?: KanbanColumn[];
|
||||
}): Promise<TaskList> {
|
||||
return request<TaskList>('/api/v1/task-lists', {
|
||||
method: 'POST',
|
||||
@@ -365,6 +369,7 @@ export const api = {
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
columns?: KanbanColumn[];
|
||||
}
|
||||
): Promise<TaskList> {
|
||||
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface Thought {
|
||||
convertedRefId?: string;
|
||||
dueDate?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
@@ -64,6 +64,13 @@ export interface Thought {
|
||||
triagedAt?: string;
|
||||
}
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
isDone?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskListShare {
|
||||
id: string;
|
||||
taskListId: string;
|
||||
@@ -92,6 +99,7 @@ export interface TaskList {
|
||||
isOwner?: boolean;
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
columns?: KanbanColumn[];
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
|
||||
@@ -690,13 +690,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
Tags *[]string `json:"tags"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks)
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -1590,17 +1591,18 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
listID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
TeamID *string `json:"teamId"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
Color *string `json:"color"`
|
||||
TeamID *string `json:"teamId"`
|
||||
SpaceID *string `json:"spaceId"`
|
||||
Columns *[]domain.KanbanColumn `json:"columns"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID)
|
||||
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
|
||||
@@ -175,6 +175,13 @@ type TaskListMember struct {
|
||||
Role string `json:"role"` // owner | member
|
||||
}
|
||||
|
||||
type KanbanColumn struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
IsDone bool `json:"isDone,omitempty"`
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
@@ -186,6 +193,7 @@ type TaskList struct {
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
|
||||
+65
-17
@@ -129,6 +129,7 @@ func (s *Store) migrate() error {
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📋',
|
||||
color TEXT NOT NULL DEFAULT '#2563eb',
|
||||
columns TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -324,6 +325,7 @@ func (s *Store) migrate() error {
|
||||
"CREATE INDEX IF NOT EXISTS idx_doc_shares_doc ON doc_shares(doc_id);",
|
||||
|
||||
"ALTER TABLE task_lists ADD COLUMN team_id TEXT NOT NULL DEFAULT '';",
|
||||
"ALTER TABLE task_lists ADD COLUMN columns TEXT NOT NULL DEFAULT '[]';",
|
||||
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
|
||||
@@ -1028,7 +1030,7 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
||||
return s.GetThoughtByID(userID, thoughtID)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask) (*domain.Thought, error) {
|
||||
func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, isCompleted *bool, dueDate *string, priority *string, taskListID *string, kanbanStatus *string, notes *string, subtasks *[]domain.SubTask, tags *[]string) (*domain.Thought, error) {
|
||||
thought, err := s.GetThoughtByID(userID, thoughtID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1038,6 +1040,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
thought.Content = strings.TrimSpace(*content)
|
||||
thought.Tags = domain.ExtractTags(thought.Content)
|
||||
}
|
||||
if tags != nil {
|
||||
thought.Tags = *tags
|
||||
}
|
||||
if isCompleted != nil {
|
||||
thought.IsCompleted = *isCompleted
|
||||
if *isCompleted {
|
||||
@@ -1049,18 +1054,24 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
|
||||
if dueDate != nil {
|
||||
thought.DueDate = *dueDate
|
||||
}
|
||||
if priority != nil && *priority != "" {
|
||||
thought.Priority = *priority
|
||||
if priority != nil {
|
||||
p := strings.ToLower(strings.TrimSpace(*priority))
|
||||
if p == "" || p == "none" {
|
||||
p = "normal"
|
||||
}
|
||||
thought.Priority = p
|
||||
}
|
||||
if taskListID != nil {
|
||||
thought.TaskListID = *taskListID
|
||||
}
|
||||
if kanbanStatus != nil && *kanbanStatus != "" {
|
||||
thought.KanbanStatus = *kanbanStatus
|
||||
if *kanbanStatus == "done" {
|
||||
thought.IsCompleted = true
|
||||
} else {
|
||||
thought.IsCompleted = false
|
||||
if isCompleted == nil {
|
||||
if *kanbanStatus == "done" {
|
||||
thought.IsCompleted = true
|
||||
} else {
|
||||
thought.IsCompleted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if notes != nil {
|
||||
@@ -2551,7 +2562,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
|
||||
// --- TASK LISTS ---
|
||||
|
||||
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string) (*domain.TaskList, error) {
|
||||
func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color string, columns ...[]domain.KanbanColumn) (*domain.TaskList, error) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Meine Aufgaben"
|
||||
}
|
||||
@@ -2561,6 +2572,16 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
|
||||
if color == "" {
|
||||
color = "#2563eb"
|
||||
}
|
||||
cols := []domain.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"},
|
||||
}
|
||||
if len(columns) > 0 && len(columns[0]) > 0 {
|
||||
cols = columns[0]
|
||||
}
|
||||
colsJSON, _ := json.Marshal(cols)
|
||||
now := domain.NowISO()
|
||||
tl := &domain.TaskList{
|
||||
ID: domain.NewID("tkl"),
|
||||
@@ -2570,13 +2591,14 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
|
||||
Title: strings.TrimSpace(title),
|
||||
Icon: icon,
|
||||
Color: color,
|
||||
Columns: cols,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
IsOwner: true,
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, tl.CreatedAt, tl.UpdatedAt,
|
||||
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, columns, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2729,7 +2751,7 @@ func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMemb
|
||||
|
||||
func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE user_id = ?
|
||||
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))
|
||||
@@ -2746,11 +2768,23 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
return []domain.TaskList{}, err
|
||||
}
|
||||
|
||||
defaultCols := []domain.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"},
|
||||
}
|
||||
|
||||
list := make([]domain.TaskList, 0)
|
||||
for rows.Next() {
|
||||
var tl domain.TaskList
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
var colsJSON string
|
||||
if err := rows.Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt); err == nil {
|
||||
tl.IsOwner = (tl.UserID == userID)
|
||||
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
|
||||
if len(tl.Columns) == 0 {
|
||||
tl.Columns = defaultCols
|
||||
}
|
||||
list = append(list, tl)
|
||||
}
|
||||
}
|
||||
@@ -2779,8 +2813,9 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
|
||||
|
||||
func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
|
||||
var tl domain.TaskList
|
||||
var colsJSON string
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, created_at, updated_at
|
||||
`SELECT id, user_id, space_id, COALESCE(team_id, ''), title, icon, color, COALESCE(columns, '[]'), created_at, updated_at
|
||||
FROM task_lists
|
||||
WHERE id = ? AND (
|
||||
user_id = ?
|
||||
@@ -2793,11 +2828,20 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
|
||||
)`,
|
||||
listID, userID, userID, userID, userID, userID,
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.TeamID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tl.IsOwner = (tl.UserID == userID)
|
||||
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
|
||||
if len(tl.Columns) == 0 {
|
||||
tl.Columns = []domain.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"},
|
||||
}
|
||||
}
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE task_list_id = ? AND is_completed = 0 AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task'))`, tl.ID).Scan(&tl.TasksCount)
|
||||
_ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
|
||||
if tl.TeamID != "" {
|
||||
@@ -2809,7 +2853,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
|
||||
return &tl, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string) (*domain.TaskList, error) {
|
||||
func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID, spaceID *string, columns *[]domain.KanbanColumn) (*domain.TaskList, error) {
|
||||
tl, err := s.GetTaskListByID(userID, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2826,6 +2870,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
|
||||
if spaceID != nil {
|
||||
tl.SpaceID = *spaceID
|
||||
}
|
||||
if columns != nil {
|
||||
tl.Columns = *columns
|
||||
}
|
||||
if teamID != nil {
|
||||
oldTeamID := tl.TeamID
|
||||
tl.TeamID = *teamID
|
||||
@@ -2837,9 +2884,10 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
|
||||
}
|
||||
}
|
||||
tl.UpdatedAt = domain.NowISO()
|
||||
colsJSON, _ := json.Marshal(tl.Columns)
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, updated_at = ? WHERE id = ?`,
|
||||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, tl.UpdatedAt, listID,
|
||||
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, columns = ?, updated_at = ? WHERE id = ?`,
|
||||
tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, string(colsJSON), tl.UpdatedAt, listID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in new issue
Block a user