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
+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
@@ -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}`, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user