diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..020910b --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + DndContext, + DragOverlay, + PointerSensor, + TouchSensor, + closestCorners, + useDraggable, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { restrictToWindowEdges } from "@dnd-kit/modifiers"; +import { GitBranch, GripVertical, NotebookPen } from "lucide-react"; +import type { ProjectStatus } from "@prisma/client"; +import { CiBadge } from "@/components/CiBadge"; +import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status"; +import { cn, truncate } from "@/lib/utils"; +import type { ProjectListItem } from "@/types"; + +/** + * Statusspalten mit Ziehen und Ablegen. + * + * Der Statuswechsel wird sofort angezeigt und erst danach gespeichert; + * scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge + * innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board + * gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden. + */ +export function KanbanBoard({ + projects, + showArchived, +}: { + projects: ProjectListItem[]; + showArchived: boolean; +}) { + const router = useRouter(); + const [overrides, setOverrides] = useState>({}); + const [activeId, setActiveId] = useState(null); + + // Eine Verschiebung, die der Server bestaetigt hat, braucht keine + // oertliche Ausnahme mehr. + useEffect(() => { + setOverrides((current) => { + const next: Record = {}; + for (const [id, status] of Object.entries(current)) { + const project = projects.find((entry) => entry.id === id); + if (project && project.status !== status) next[id] = status; + } + return Object.keys(next).length === Object.keys(current).length ? current : next; + }); + }, [projects]); + + const sensors = useSensors( + // Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte + // nicht mehr anklicken. + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), + ); + + const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status; + + const columns = useMemo(() => { + const visible = STATUS_ORDER.filter( + (status) => showArchived || status !== "ARCHIVED", + ); + return visible.map((status) => ({ + status, + items: projects.filter((project) => statusOf(project) === status), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projects, overrides, showArchived]); + + const active = activeId ? projects.find((project) => project.id === activeId) : null; + + function onDragStart(event: DragStartEvent) { + setActiveId(String(event.active.id)); + } + + async function onDragEnd(event: DragEndEvent) { + setActiveId(null); + + const id = String(event.active.id); + const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null; + if (!target) return; + + const project = projects.find((entry) => entry.id === id); + if (!project) return; + + const previous = statusOf(project); + if (previous === target) return; + + setOverrides((current) => ({ ...current, [id]: target })); + + try { + const response = await fetch(`/api/projects/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: target }), + }); + if (!response.ok) throw new Error("failed"); + router.refresh(); + } catch { + setOverrides((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); + } + } + + return ( + setActiveId(null)} + > +
+ {columns.map((column) => ( + + {column.items.map((project) => ( + + ))} + + ))} +
+ + {/* Die mitgezogene Karte haengt am Cursor, nicht in der Spalte. */} + + {active ? : null} + +
+ ); +} + +function Column({ + status, + count, + children, +}: { + status: ProjectStatus; + count: number; + children: React.ReactNode; +}) { + const meta = STATUS_META[status]; + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+
+ +

+ {meta.label} +

+ + {count} + +
+ +
+ {children} + {count === 0 ? ( +

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

+ ) : null} +
+
+ ); +} + +function KanbanCard({ + project, + overlay = false, +}: { + project: ProjectListItem; + overlay?: boolean; +}) { + const accent = accentOf(project.accent); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id }); + + return ( +
+
+ ); +}