"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { DndContext, DragOverlay, PointerSensor, TouchSensor, pointerWithin, useDroppable, useSensor, useSensors, type DragEndEvent, type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { restrictToWindowEdges } from "@dnd-kit/modifiers"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { CheckSquare, Loader2, Plus } from "lucide-react"; import type { Task, TaskStatus } from "@prisma/client"; import { TaskCard } from "@/components/tasks/TaskCard"; import { TaskDialog } from "@/components/tasks/TaskDialog"; import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { cn } from "@/lib/utils"; type Columns = Record; function group(tasks: Task[]): Columns { const columns = { TODO: [], DOING: [], BLOCKED: [], DONE: [] } as Columns; for (const task of [...tasks].sort((a, b) => a.position - b.position)) { columns[task.status].push(task); } return columns; } /** * Aufgabenbrett eines Projekts. * * Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte * hier bedeutsam, deshalb @dnd-kit/sortable statt einfacher Ablageflaechen. * Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue * Ordnung als Ganzes gespeichert: je Spalte die vollstaendige Liste. Das ist * wiederholbar und kommt ohne Nachrechnen von Positionen im Browser aus. */ export function TaskBoard({ projectId, tasks, progressFromTasks, }: { projectId: string; tasks: Task[]; progressFromTasks: boolean; }) { const router = useRouter(); const [columns, setColumns] = useState(() => group(tasks)); const [activeId, setActiveId] = useState(null); const [editing, setEditing] = useState(null); const [creatingIn, setCreatingIn] = useState(null); const [saving, setSaving] = useState(false); const dragging = useRef(false); // Serverstand uebernehmen, solange nicht gerade gezogen wird – sonst // wuerde die Karte mitten in der Bewegung zurueckspringen. useEffect(() => { if (!dragging.current) setColumns(group(tasks)); }, [tasks]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), ); const allTasks = useMemo(() => Object.values(columns).flat(), [columns]); const active = activeId ? allTasks.find((task) => task.id === activeId) ?? null : null; const total = allTasks.length; const done = columns.DONE.length; function containerOf(id: string): TaskStatus | null { if (TASK_STATUS_ORDER.includes(id as TaskStatus)) return id as TaskStatus; for (const status of TASK_STATUS_ORDER) { if (columns[status].some((task) => task.id === id)) return status; } return null; } function onDragStart(event: DragStartEvent) { dragging.current = true; setActiveId(String(event.active.id)); } /** Wechsel der Spalte passiert schon beim Darueberziehen, nicht erst beim Ablegen. */ function onDragOver(event: DragOverEvent) { const activeKey = String(event.active.id); const overKey = event.over ? String(event.over.id) : null; if (!overKey) return; const from = containerOf(activeKey); const to = containerOf(overKey); if (!from || !to || from === to) return; setColumns((current) => { const moved = current[from].find((task) => task.id === activeKey); if (!moved) return current; const targetIndex = current[to].findIndex((task) => task.id === overKey); const insertAt = targetIndex >= 0 ? targetIndex : current[to].length; return { ...current, [from]: current[from].filter((task) => task.id !== activeKey), [to]: [ ...current[to].slice(0, insertAt), { ...moved, status: to }, ...current[to].slice(insertAt), ], }; }); } async function onDragEnd(event: DragEndEvent) { const activeKey = String(event.active.id); const overKey = event.over ? String(event.over.id) : null; let next = columns; if (overKey) { const container = containerOf(activeKey); const overContainer = containerOf(overKey); if (container && container === overContainer) { const list = columns[container]; const oldIndex = list.findIndex((task) => task.id === activeKey); const newIndex = list.findIndex((task) => task.id === overKey); if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { const reordered = [...list]; const [moved] = reordered.splice(oldIndex, 1); reordered.splice(newIndex, 0, moved); next = { ...columns, [container]: reordered }; setColumns(next); } } } setActiveId(null); dragging.current = false; await persist(next); } async function persist(next: Columns) { setSaving(true); try { const payload = { columns: Object.fromEntries( TASK_STATUS_ORDER.map((status) => [status, next[status].map((task) => task.id)]), ), }; const response = await fetch(`/api/projects/${projectId}/tasks/reorder`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) throw new Error("failed"); router.refresh(); } catch { // Zurueck auf den Serverstand – die Bewegung war dann nur optisch. setColumns(group(tasks)); } finally { setSaving(false); } } return (

Aufgaben {total > 0 ? ( {done}/{total} ) : null} {saving ? : null}

{progressFromTasks ? "Der Fortschritt des Projekts folgt diesen Aufgaben." : "Ziehen zum Verschieben und Sortieren."}

{ setActiveId(null); dragging.current = false; setColumns(group(tasks)); }} >
{TASK_STATUS_ORDER.map((status) => ( setCreatingIn(status)} onEdit={setEditing} /> ))}
{active ? : null}
{ setCreatingIn(null); setEditing(null); }} />
); } function TaskColumn({ status, tasks, onAdd, onEdit, }: { status: TaskStatus; tasks: Task[]; onAdd: () => void; onEdit: (task: Task) => void; }) { const meta = TASK_STATUS_META[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); return (
task.id)} strategy={verticalListSortingStrategy} >
{tasks.map((task) => ( ))} {tasks.length === 0 ? (

{isOver ? "Hier ablegen" : "leer"}

) : null}
); } function SortableTask({ task, onEdit }: { task: Task; onEdit: (task: Task) => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, }); return (
); }