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 (