From 1400bca047ad00f64b340f8d81b2e36c14a8f799 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 14:30:17 +0200 Subject: [PATCH] feat(board): freie Reihenfolge im Projektbrett Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das Projektbrett der gewaehlten Sortierung folgte, war ein Bruch. Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend. Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat. Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht, sonst bestuende der Verlauf bald aus nichts anderem. Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang wird jetzt an allen vier Stellen gesetzt. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 20 ++ prisma/schema.prisma | 4 + src/app/api/projects/[id]/route.ts | 14 +- src/app/api/projects/import/route.ts | 4 +- src/app/api/projects/reorder/route.ts | 94 +++++++ src/app/api/projects/route.ts | 4 +- src/components/KanbanBoard.tsx | 244 +++++++++++++----- src/lib/projects.ts | 21 +- src/lib/validation.ts | 35 +++ 9 files changed, 370 insertions(+), 70 deletions(-) create mode 100644 prisma/migrations/20260726240000_project_position/migration.sql create mode 100644 src/app/api/projects/reorder/route.ts diff --git a/prisma/migrations/20260726240000_project_position/migration.sql b/prisma/migrations/20260726240000_project_position/migration.sql new file mode 100644 index 0000000..12aa1fd --- /dev/null +++ b/prisma/migrations/20260726240000_project_position/migration.sql @@ -0,0 +1,20 @@ +-- Freie Reihenfolge im Projektbrett. +-- +-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und +-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle +-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei +-- jedem Laden anders ergeben koennte. + +ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0; + +UPDATE "Project" AS p +SET "position" = nummeriert.rang - 1 +FROM ( + SELECT "id", ROW_NUMBER() OVER ( + PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id" + ) AS rang + FROM "Project" +) AS nummeriert +WHERE nummeriert."id" = p."id"; + +CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7ece2c8..181cea4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -315,6 +315,9 @@ model Project { accent String @default("violet") tags String[] @default([]) favorite Boolean @default(false) + /// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die + /// uebrigen Ansichten folgen der gewaehlten Sortierung. + position Int @default(0) /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Einrichten des ersten Kontos uebernommen werden koennen. @@ -343,6 +346,7 @@ model Project { @@index([status]) @@index([updatedAt]) @@index([ownerId]) + @@index([ownerId, status, position]) } /// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren – diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index f755a16..73af9ea 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -3,7 +3,12 @@ import { NextResponse } from "next/server"; import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; +import { + nextProjectPosition, + projectDetailInclude, + projectListInclude, + uniqueSlug, +} from "@/lib/projects"; import { applyLeadingRepo, type RepoInput } from "@/lib/repositories"; import { jsonError, readJsonBody } from "@/lib/security/request"; import { refreshDerivedProgress } from "@/lib/tasks"; @@ -82,6 +87,13 @@ export async function PATCH(request: Request, { params }: Params) { if (data[key] !== undefined) update[key] = data[key]; } + // Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt + // seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst + // angekommen ist. + if (data.status !== undefined && data.status !== existing.status) { + update.position = await nextProjectPosition(auth.user.id, data.status); + } + await prisma.project.update({ where: { id }, data: update }); // Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin diff --git a/src/app/api/projects/import/route.ts b/src/app/api/projects/import/route.ts index 40e5082..88425dd 100644 --- a/src/app/api/projects/import/route.ts +++ b/src/app/api/projects/import/route.ts @@ -8,7 +8,7 @@ import { repositoriesOf, type ExportedRepository, } from "@/lib/export/format"; -import { uniqueSlug } from "@/lib/projects"; +import { nextProjectPosition, uniqueSlug } from "@/lib/projects"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { IMPORT_MAX_BODY_BYTES, @@ -164,6 +164,8 @@ export async function POST(request: Request) { } const data = { + // Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei. + position: await nextProjectPosition(auth.user.id, incoming.status), name: incoming.name, summary: incoming.summary ?? null, description: incoming.description ?? null, diff --git a/src/app/api/projects/reorder/route.ts b/src/app/api/projects/reorder/route.ts new file mode 100644 index 0000000..3a688ec --- /dev/null +++ b/src/app/api/projects/reorder/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import type { Prisma, ProjectStatus } from "@prisma/client"; +import { describeStatus, recordActivities } from "@/lib/activity"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { readJsonBody } from "@/lib/security/request"; +import { formatZodError, projectReorderSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Neue Reihenfolge im Brett nach dem Ziehen. + * + * Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste + * uebergeben. Status und Position ergeben sich daraus eindeutig; ein + * verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden + * anzurichten. + */ +export async function PATCH(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = projectReorderSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const columns = parsed.data.columns as Partial>; + const requestedIds = Object.values(columns).flat(); + + // Nur eigene Projekte anfassen – sonst liessen sich fremde ueber + // untergeschobene IDs verschieben. + const owned = await prisma.project.findMany({ + where: { ownerId: auth.user.id, id: { in: requestedIds } }, + select: { id: true, status: true, updatedAt: true }, + }); + const ownedIds = new Set(owned.map((project) => project.id)); + const vorher = new Map(owned.map((project) => [project.id, project])); + + const updates: Prisma.PrismaPromise[] = []; + const entries: Parameters[0] = []; + + for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) { + ids.forEach((projectId, index) => { + const before = vorher.get(projectId); + if (!ownedIds.has(projectId) || !before) return; + + const gewandert = before.status !== status; + + updates.push( + prisma.project.update({ + where: { id: projectId }, + data: { + status, + position: index, + // Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst + // schoebe ein Aufraeumen im Brett saemtliche Projekte in der + // Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die + // Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat. + ...(gewandert ? {} : { updatedAt: before.updatedAt }), + }, + }), + ); + + // Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren + // nicht – sonst besteht er bald nur noch aus Rangaenderungen. + if (gewandert) { + entries.push({ + projectId, + userId: auth.user.id, + kind: "STATUS_CHANGED", + summary: describeStatus(before.status, status), + meta: { from: before.status, to: status }, + }); + } + }); + } + + if (updates.length > 0) { + await prisma.$transaction(updates); + await recordActivities(entries); + } + + return NextResponse.json({ + updated: updates.length, + ignored: requestedIds.length - updates.length, + }); +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 76049f9..de585f2 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { recordActivity } from "@/lib/activity"; import { requireUserApi } from "@/lib/auth/guard"; import { prisma } from "@/lib/db"; -import { projectListInclude, uniqueSlug } from "@/lib/projects"; +import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects"; import { applyLeadingRepo } from "@/lib/repositories"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; @@ -87,6 +87,8 @@ export async function POST(request: Request) { accent: data.accent, tags: data.tags, favorite: data.favorite, + // Hinten an die Statusspalte des Bretts. + position: await nextProjectPosition(auth.user.id, data.status), }, select: { id: true }, }); diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx index f5c94d9..522edec 100644 --- a/src/components/KanbanBoard.tsx +++ b/src/components/KanbanBoard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { @@ -8,53 +8,81 @@ import { DragOverlay, PointerSensor, TouchSensor, - closestCorners, - useDraggable, + pointerWithin, useDroppable, useSensor, useSensors, type DragEndEvent, + type DragOverEvent, type DragStartEvent, } from "@dnd-kit/core"; import { restrictToWindowEdges } from "@dnd-kit/modifiers"; -import { GitBranch, GripVertical, NotebookPen } from "lucide-react"; +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 und Ablegen. + * Statusspalten mit Ziehen, Ablegen und freier Reihenfolge. * - * 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. + * 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 [overrides, setOverrides] = useState>({}); const [activeId, setActiveId] = useState(null); + const [saving, setSaving] = useState(false); - // 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; + 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 Object.keys(next).length === Object.keys(current).length ? current : next; - }); - }, [projects]); + 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 @@ -63,72 +91,138 @@ export function KanbanBoard({ useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), ); - const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status; + const active = activeId + ? Object.values(columns).flat().find((project) => project.id === activeId) ?? null + : null; - const columns = useMemo(() => { - const visible = STATUS_ORDER.filter( - (status) => showArchived || status !== "ARCHIVED", + /** 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 ); - 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) { + 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); + } - 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 })); - + async function persist(next: Columns) { + setSaving(true); try { - const response = await fetch(`/api/projects/${id}`, { + const response = await fetch("/api/projects/reorder", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: target }), + 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 { - setOverrides((current) => { - const next = { ...current }; - delete next[id]; - return next; - }); + // Zurueck auf den Serverstand – die Bewegung war dann nur optisch. + setColumns(group(projects)); + } finally { + setSaving(false); } } return ( setActiveId(null)} + onDragCancel={() => { + setActiveId(null); + dragging.current = false; + setColumns(group(projects)); + }} > + {saving ? ( +

+ + Reihenfolge wird gespeichert… +

+ ) : null} +
- {columns.map((column) => ( - - {column.items.map((project) => ( - - ))} + {sichtbar.map((status) => ( + + project.id)} + strategy={verticalListSortingStrategy} + > + {columns[status].map((project) => ( + + ))} + ))}
@@ -196,28 +290,36 @@ function Column({ 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, isDragging } = useDraggable({ id: project.id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: project.id, + }); return (