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

This commit is contained in:
sandrodach committed 2026-10-25 13:13:11 +01:00
1 parent 0afa2f9c2d
commit b5df4a34b4
8 files changed
+1826 -668

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
+7 -2
View File
@@ -6,6 +6,7 @@ import {
DocumentBlock, DocumentBlock,
DocShare, DocShare,
DocLockInfo, DocLockInfo,
KanbanColumn,
ListItem, ListItem,
Organization, Organization,
PriorityLevel, PriorityLevel,
@@ -313,11 +314,12 @@ export const api = {
content?: string; content?: string;
isCompleted?: boolean; isCompleted?: boolean;
dueDate?: string; dueDate?: string;
priority?: PriorityLevel; priority?: string;
taskListId?: string; taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done'; kanbanStatus?: string;
notes?: string; notes?: string;
subtasks?: SubTask[]; subtasks?: SubTask[];
tags?: string[];
} }
): Promise<Thought> { ): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, { return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
@@ -341,6 +343,7 @@ export const api = {
...tl, ...tl,
members: ensureArray(tl.members), members: ensureArray(tl.members),
shares: ensureArray(tl.shares), shares: ensureArray(tl.shares),
columns: ensureArray(tl.columns),
})); }));
}, },
@@ -350,6 +353,7 @@ export const api = {
color?: string; color?: string;
teamId?: string; teamId?: string;
spaceId?: string; spaceId?: string;
columns?: KanbanColumn[];
}): Promise<TaskList> { }): Promise<TaskList> {
return request<TaskList>('/api/v1/task-lists', { return request<TaskList>('/api/v1/task-lists', {
method: 'POST', method: 'POST',
@@ -365,6 +369,7 @@ export const api = {
color?: string; color?: string;
teamId?: string; teamId?: string;
spaceId?: string; spaceId?: string;
columns?: KanbanColumn[];
} }
): Promise<TaskList> { ): Promise<TaskList> {
return request<TaskList>(`/api/v1/task-lists/${id}`, { return request<TaskList>(`/api/v1/task-lists/${id}`, {
+9 -1
View File
@@ -56,7 +56,7 @@ export interface Thought {
convertedRefId?: string; convertedRefId?: string;
dueDate?: string; dueDate?: string;
taskListId?: string; taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done'; kanbanStatus?: string;
notes?: string; notes?: string;
subtasks?: SubTask[]; subtasks?: SubTask[];
alarmSent?: boolean; alarmSent?: boolean;
@@ -64,6 +64,13 @@ export interface Thought {
triagedAt?: string; triagedAt?: string;
} }
export interface KanbanColumn {
id: string;
name: string;
color?: string;
isDone?: boolean;
}
export interface TaskListShare { export interface TaskListShare {
id: string; id: string;
taskListId: string; taskListId: string;
@@ -92,6 +99,7 @@ export interface TaskList {
isOwner?: boolean; isOwner?: boolean;
members?: TaskListMember[]; members?: TaskListMember[];
shares?: TaskListShare[]; shares?: TaskListShare[];
columns?: KanbanColumn[];
title: string; title: string;
icon: string; icon: string;
color: string; color: string;
+4 -2
View File
@@ -690,13 +690,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
KanbanStatus *string `json:"kanbanStatus"` KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"` Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
@@ -1595,12 +1596,13 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
Color *string `json:"color"` Color *string `json:"color"`
TeamID *string `json:"teamId"` TeamID *string `json:"teamId"`
SpaceID *string `json:"spaceId"` SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
+8
View File
@@ -175,6 +175,13 @@ type TaskListMember struct {
Role string `json:"role"` // owner | member 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 { type TaskList struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
@@ -186,6 +193,7 @@ type TaskList struct {
IsOwner bool `json:"isOwner"` IsOwner bool `json:"isOwner"`
Members []TaskListMember `json:"members,omitempty"` Members []TaskListMember `json:"members,omitempty"`
Shares []TaskListShare `json:"shares,omitempty"` Shares []TaskListShare `json:"shares,omitempty"`
Columns []KanbanColumn `json:"columns,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon"` Icon string `json:"icon"`
Color string `json:"color"` Color string `json:"color"`
+61 -13
View File
@@ -129,6 +129,7 @@ func (s *Store) migrate() error {
title TEXT NOT NULL, title TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📋', icon TEXT NOT NULL DEFAULT '📋',
color TEXT NOT NULL DEFAULT '#2563eb', color TEXT NOT NULL DEFAULT '#2563eb',
columns TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE 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);", "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 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 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_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);", "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) 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) thought, err := s.GetThoughtByID(userID, thoughtID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1038,6 +1040,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
thought.Content = strings.TrimSpace(*content) thought.Content = strings.TrimSpace(*content)
thought.Tags = domain.ExtractTags(thought.Content) thought.Tags = domain.ExtractTags(thought.Content)
} }
if tags != nil {
thought.Tags = *tags
}
if isCompleted != nil { if isCompleted != nil {
thought.IsCompleted = *isCompleted thought.IsCompleted = *isCompleted
if *isCompleted { if *isCompleted {
@@ -1049,20 +1054,26 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string,
if dueDate != nil { if dueDate != nil {
thought.DueDate = *dueDate thought.DueDate = *dueDate
} }
if priority != nil && *priority != "" { if priority != nil {
thought.Priority = *priority p := strings.ToLower(strings.TrimSpace(*priority))
if p == "" || p == "none" {
p = "normal"
}
thought.Priority = p
} }
if taskListID != nil { if taskListID != nil {
thought.TaskListID = *taskListID thought.TaskListID = *taskListID
} }
if kanbanStatus != nil && *kanbanStatus != "" { if kanbanStatus != nil && *kanbanStatus != "" {
thought.KanbanStatus = *kanbanStatus thought.KanbanStatus = *kanbanStatus
if isCompleted == nil {
if *kanbanStatus == "done" { if *kanbanStatus == "done" {
thought.IsCompleted = true thought.IsCompleted = true
} else { } else {
thought.IsCompleted = false thought.IsCompleted = false
} }
} }
}
if notes != nil { if notes != nil {
thought.Notes = *notes thought.Notes = *notes
} }
@@ -2551,7 +2562,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
// --- TASK LISTS --- // --- 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) == "" { if strings.TrimSpace(title) == "" {
title = "Meine Aufgaben" title = "Meine Aufgaben"
} }
@@ -2561,6 +2572,16 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
if color == "" { if color == "" {
color = "#2563eb" 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() now := domain.NowISO()
tl := &domain.TaskList{ tl := &domain.TaskList{
ID: domain.NewID("tkl"), ID: domain.NewID("tkl"),
@@ -2570,13 +2591,14 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin
Title: strings.TrimSpace(title), Title: strings.TrimSpace(title),
Icon: icon, Icon: icon,
Color: color, Color: color,
Columns: cols,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
IsOwner: true, IsOwner: true,
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO task_lists (id, user_id, space_id, team_id, title, icon, color, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, `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, tl.CreatedAt, tl.UpdatedAt, tl.ID, tl.UserID, tl.SpaceID, tl.TeamID, tl.Title, tl.Icon, tl.Color, string(colsJSON), tl.CreatedAt, tl.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err 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) { func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) {
rows, err := s.db.Query( 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 FROM task_lists
WHERE user_id = ? WHERE user_id = ?
OR (team_id != '' AND team_id IN (SELECT team_id FROM team_members 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 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) list := make([]domain.TaskList, 0)
for rows.Next() { for rows.Next() {
var tl domain.TaskList 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) tl.IsOwner = (tl.UserID == userID)
_ = json.Unmarshal([]byte(colsJSON), &tl.Columns)
if len(tl.Columns) == 0 {
tl.Columns = defaultCols
}
list = append(list, tl) 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) { func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) {
var tl domain.TaskList var tl domain.TaskList
var colsJSON string
err := s.db.QueryRow( 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 FROM task_lists
WHERE id = ? AND ( WHERE id = ? AND (
user_id = ? 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 = ?)) OR (space_id != '' AND space_id IN (SELECT space_id FROM space_members WHERE user_id = ?))
)`, )`,
listID, userID, userID, userID, userID, userID, 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 { if err != nil {
return nil, err return nil, err
} }
tl.IsOwner = (tl.UserID == userID) 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 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) _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, tl.UserID).Scan(&tl.OwnerName)
if tl.TeamID != "" { if tl.TeamID != "" {
@@ -2809,7 +2853,7 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error)
return &tl, nil 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) tl, err := s.GetTaskListByID(userID, listID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -2826,6 +2870,9 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
if spaceID != nil { if spaceID != nil {
tl.SpaceID = *spaceID tl.SpaceID = *spaceID
} }
if columns != nil {
tl.Columns = *columns
}
if teamID != nil { if teamID != nil {
oldTeamID := tl.TeamID oldTeamID := tl.TeamID
tl.TeamID = *teamID tl.TeamID = *teamID
@@ -2837,9 +2884,10 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, teamID
} }
} }
tl.UpdatedAt = domain.NowISO() tl.UpdatedAt = domain.NowISO()
colsJSON, _ := json.Marshal(tl.Columns)
_, err = s.db.Exec( _, err = s.db.Exec(
`UPDATE task_lists SET title = ?, icon = ?, color = ?, space_id = ?, team_id = ?, updated_at = ? WHERE id = ?`, `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, tl.UpdatedAt, listID, tl.Title, tl.Icon, tl.Color, tl.SpaceID, tl.TeamID, string(colsJSON), tl.UpdatedAt, listID,
) )
if err != nil { if err != nil {
return nil, err return nil, err