"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; 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 { GitBranch, GripVertical, Loader2, 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"; type Columns = Record; /** * Statusspalten mit Ziehen, Ablegen und freier Reihenfolge. * * Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt * gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch * nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und * Gruppen, hier zaehlt die Anordnung von Hand. * * Verschieben wird sofort angezeigt und erst danach gespeichert; scheitert * das Speichern, springt alles auf den Serverstand zurueck. */ export function KanbanBoard({ projects, showArchived, selection, onToggleSelect, }: { projects: ProjectListItem[]; showArchived: boolean; selection: Set; onToggleSelect: (id: string, selected: boolean) => void; }) { const router = useRouter(); const [activeId, setActiveId] = useState(null); const [saving, setSaving] = useState(false); const sichtbar = useMemo( () => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"), [showArchived], ); const group = useMemo( () => (list: ProjectListItem[]): Columns => { const leer = Object.fromEntries( STATUS_ORDER.map((status) => [status, [] as ProjectListItem[]]), ) as Columns; for (const project of list) leer[project.status].push(project); for (const status of STATUS_ORDER) { // Bei gleicher Position bleibt es bei der Reihenfolge, in der die // Liste kam – das ist die im Kopf gewaehlte Sortierung. leer[status].sort((a, b) => a.position - b.position); } return leer; }, [], ); const [columns, setColumns] = useState(() => group(projects)); // Waehrend eines Zuges nicht dazwischenfunken: der Serverstand ist dann // noch der alte und wuerde die Karte zurueckspringen lassen. const dragging = useRef(false); useEffect(() => { if (!dragging.current) setColumns(group(projects)); }, [projects, group]); 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 active = activeId ? Object.values(columns).flat().find((project) => project.id === activeId) ?? null : null; /** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */ function containerOf(key: string): ProjectStatus | null { if ((STATUS_ORDER as string[]).includes(key)) return key as ProjectStatus; return ( STATUS_ORDER.find((status) => columns[status].some((project) => project.id === key)) ?? null ); } function onDragStart(event: DragStartEvent) { dragging.current = true; setActiveId(String(event.active.id)); } 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((project) => project.id === activeKey); if (!moved) return current; const zielIndex = current[to].findIndex((project) => project.id === overKey); const einfuegenBei = zielIndex >= 0 ? zielIndex : current[to].length; return { ...current, [from]: current[from].filter((project) => project.id !== activeKey), [to]: [ ...current[to].slice(0, einfuegenBei), { ...moved, status: to }, ...current[to].slice(einfuegenBei), ], }; }); } 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 ueber = containerOf(overKey); if (container && container === ueber) { const list = columns[container]; const alt = list.findIndex((project) => project.id === activeKey); const neu = list.findIndex((project) => project.id === overKey); if (alt >= 0 && neu >= 0 && alt !== neu) { const sortiert = [...list]; const [moved] = sortiert.splice(alt, 1); sortiert.splice(neu, 0, moved); next = { ...columns, [container]: sortiert }; setColumns(next); } } } setActiveId(null); dragging.current = false; await persist(next); } async function persist(next: Columns) { setSaving(true); try { const response = await fetch("/api/projects/reorder", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ columns: Object.fromEntries( sichtbar.map((status) => [status, next[status].map((project) => project.id)]), ), }), }); if (!response.ok) throw new Error("failed"); router.refresh(); } catch { // Zurueck auf den Serverstand – die Bewegung war dann nur optisch. setColumns(group(projects)); } finally { setSaving(false); } } return ( { setActiveId(null); dragging.current = false; setColumns(group(projects)); }} > {saving ? (

Reihenfolge wird gespeichert…

) : null}
{sichtbar.map((status) => ( project.id)} strategy={verticalListSortingStrategy} > {columns[status].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, selected = false, onToggleSelect, }: { project: ProjectListItem; overlay?: boolean; selected?: boolean; onToggleSelect?: (id: string, selected: boolean) => void; }) { const accent = accentOf(project.accent); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: project.id, }); return (
); }