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) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||||
@@ -315,6 +315,9 @@ model Project {
|
|||||||
accent String @default("violet")
|
accent String @default("violet")
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
favorite Boolean @default(false)
|
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
|
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
||||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||||
@@ -343,6 +346,7 @@ model Project {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([updatedAt])
|
@@index([updatedAt])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
|
@@index([ownerId, status, position])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
|
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { NextResponse } from "next/server";
|
|||||||
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
|
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
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 { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||||
import { refreshDerivedProgress } from "@/lib/tasks";
|
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];
|
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 });
|
await prisma.project.update({ where: { id }, data: update });
|
||||||
|
|
||||||
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
|
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
repositoriesOf,
|
repositoriesOf,
|
||||||
type ExportedRepository,
|
type ExportedRepository,
|
||||||
} from "@/lib/export/format";
|
} from "@/lib/export/format";
|
||||||
import { uniqueSlug } from "@/lib/projects";
|
import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
import {
|
import {
|
||||||
IMPORT_MAX_BODY_BYTES,
|
IMPORT_MAX_BODY_BYTES,
|
||||||
@@ -164,6 +164,8 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
|
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
|
||||||
|
position: await nextProjectPosition(auth.user.id, incoming.status),
|
||||||
name: incoming.name,
|
name: incoming.name,
|
||||||
summary: incoming.summary ?? null,
|
summary: incoming.summary ?? null,
|
||||||
description: incoming.description ?? null,
|
description: incoming.description ?? null,
|
||||||
|
|||||||
@@ -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<Record<ProjectStatus, string[]>>;
|
||||||
|
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<unknown>[] = [];
|
||||||
|
const entries: Parameters<typeof recordActivities>[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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { recordActivity } from "@/lib/activity";
|
import { recordActivity } from "@/lib/activity";
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
import { requireUserApi } from "@/lib/auth/guard";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { projectListInclude, uniqueSlug } from "@/lib/projects";
|
import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||||
import { applyLeadingRepo } from "@/lib/repositories";
|
import { applyLeadingRepo } from "@/lib/repositories";
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||||
@@ -87,6 +87,8 @@ export async function POST(request: Request) {
|
|||||||
accent: data.accent,
|
accent: data.accent,
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
favorite: data.favorite,
|
favorite: data.favorite,
|
||||||
|
// Hinten an die Statusspalte des Bretts.
|
||||||
|
position: await nextProjectPosition(auth.user.id, data.status),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|||||||
+178
-66
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -8,53 +8,81 @@ import {
|
|||||||
DragOverlay,
|
DragOverlay,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
closestCorners,
|
pointerWithin,
|
||||||
useDraggable,
|
|
||||||
useDroppable,
|
useDroppable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type DragOverEvent,
|
||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
|
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 type { ProjectStatus } from "@prisma/client";
|
||||||
import { CiBadge } from "@/components/CiBadge";
|
import { CiBadge } from "@/components/CiBadge";
|
||||||
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
|
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
|
||||||
import { cn, truncate } from "@/lib/utils";
|
import { cn, truncate } from "@/lib/utils";
|
||||||
import type { ProjectListItem } from "@/types";
|
import type { ProjectListItem } from "@/types";
|
||||||
|
|
||||||
|
type Columns = Record<ProjectStatus, ProjectListItem[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statusspalten mit Ziehen und Ablegen.
|
* Statusspalten mit Ziehen, Ablegen und freier Reihenfolge.
|
||||||
*
|
*
|
||||||
* Der Statuswechsel wird sofort angezeigt und erst danach gespeichert;
|
* Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
|
||||||
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge
|
* gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
|
||||||
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board
|
* nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
|
||||||
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden.
|
* 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({
|
export function KanbanBoard({
|
||||||
projects,
|
projects,
|
||||||
showArchived,
|
showArchived,
|
||||||
|
selection,
|
||||||
|
onToggleSelect,
|
||||||
}: {
|
}: {
|
||||||
projects: ProjectListItem[];
|
projects: ProjectListItem[];
|
||||||
showArchived: boolean;
|
showArchived: boolean;
|
||||||
|
selection: Set<string>;
|
||||||
|
onToggleSelect: (id: string, selected: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine
|
const sichtbar = useMemo(
|
||||||
// oertliche Ausnahme mehr.
|
() => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
|
||||||
useEffect(() => {
|
[showArchived],
|
||||||
setOverrides((current) => {
|
);
|
||||||
const next: Record<string, ProjectStatus> = {};
|
|
||||||
for (const [id, status] of Object.entries(current)) {
|
const group = useMemo(
|
||||||
const project = projects.find((entry) => entry.id === id);
|
() => (list: ProjectListItem[]): Columns => {
|
||||||
if (project && project.status !== status) next[id] = status;
|
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;
|
return leer;
|
||||||
});
|
},
|
||||||
}, [projects]);
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [columns, setColumns] = useState<Columns>(() => 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(
|
const sensors = useSensors(
|
||||||
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
|
// 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 } }),
|
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(() => {
|
/** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
|
||||||
const visible = STATUS_ORDER.filter(
|
function containerOf(key: string): ProjectStatus | null {
|
||||||
(status) => showArchived || status !== "ARCHIVED",
|
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) {
|
function onDragStart(event: DragStartEvent) {
|
||||||
|
dragging.current = true;
|
||||||
setActiveId(String(event.active.id));
|
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) {
|
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);
|
setActiveId(null);
|
||||||
|
dragging.current = false;
|
||||||
|
await persist(next);
|
||||||
|
}
|
||||||
|
|
||||||
const id = String(event.active.id);
|
async function persist(next: Columns) {
|
||||||
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null;
|
setSaving(true);
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(`/api/projects/${id}`, {
|
const response = await fetch("/api/projects/reorder", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
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");
|
if (!response.ok) throw new Error("failed");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setOverrides((current) => {
|
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
|
||||||
const next = { ...current };
|
setColumns(group(projects));
|
||||||
delete next[id];
|
} finally {
|
||||||
return next;
|
setSaving(false);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={pointerWithin}
|
||||||
modifiers={[restrictToWindowEdges]}
|
modifiers={[restrictToWindowEdges]}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onDragCancel={() => setActiveId(null)}
|
onDragCancel={() => {
|
||||||
|
setActiveId(null);
|
||||||
|
dragging.current = false;
|
||||||
|
setColumns(group(projects));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{saving ? (
|
||||||
|
<p className="mb-2 flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Reihenfolge wird gespeichert…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex gap-3 overflow-x-auto pb-3">
|
<div className="flex gap-3 overflow-x-auto pb-3">
|
||||||
{columns.map((column) => (
|
{sichtbar.map((status) => (
|
||||||
<Column key={column.status} status={column.status} count={column.items.length}>
|
<Column key={status} status={status} count={columns[status].length}>
|
||||||
{column.items.map((project) => (
|
<SortableContext
|
||||||
<KanbanCard key={project.id} project={project} />
|
items={columns[status].map((project) => project.id)}
|
||||||
))}
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{columns[status].map((project) => (
|
||||||
|
<KanbanCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
selected={selection.has(project.id)}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
</Column>
|
</Column>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -196,28 +290,36 @@ function Column({
|
|||||||
function KanbanCard({
|
function KanbanCard({
|
||||||
project,
|
project,
|
||||||
overlay = false,
|
overlay = false,
|
||||||
|
selected = false,
|
||||||
|
onToggleSelect,
|
||||||
}: {
|
}: {
|
||||||
project: ProjectListItem;
|
project: ProjectListItem;
|
||||||
overlay?: boolean;
|
overlay?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
onToggleSelect?: (id: string, selected: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const accent = accentOf(project.accent);
|
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 (
|
return (
|
||||||
<article
|
<article
|
||||||
ref={overlay ? undefined : setNodeRef}
|
ref={overlay ? undefined : setNodeRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--accent-from": accent.from,
|
||||||
|
"--accent-to": accent.to,
|
||||||
|
...(overlay ? {} : { transform: CSS.Transform.toString(transform), transition }),
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"popover group relative overflow-hidden p-3",
|
"popover group relative overflow-hidden p-3",
|
||||||
// Das Original bleibt als blasser Platzhalter stehen.
|
// Das Original bleibt als blasser Platzhalter stehen.
|
||||||
isDragging && !overlay && "opacity-30",
|
isDragging && !overlay && "opacity-30",
|
||||||
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
|
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
|
||||||
|
selected && "ring-1 ring-violet-400/60",
|
||||||
)}
|
)}
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--accent-from": accent.from,
|
|
||||||
"--accent-to": accent.to,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
|
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
|
||||||
|
|
||||||
@@ -233,6 +335,16 @@ function KanbanCard({
|
|||||||
<GripVertical className="h-3.5 w-3.5" />
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{onToggleSelect ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
|
||||||
|
className="mt-1 h-3.5 w-3.5 shrink-0 accent-violet-500"
|
||||||
|
aria-label={`${project.name} auswaehlen`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/projects/${project.id}`}
|
href={`/projects/${project.id}`}
|
||||||
|
|||||||
+20
-1
@@ -1,4 +1,4 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma, type ProjectStatus } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { slugify } from "@/lib/utils";
|
import { slugify } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -28,6 +28,25 @@ export async function uniqueSlug(
|
|||||||
return `${base}-${Date.now()}`;
|
return `${base}-${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naechste freie Position am Ende einer Statusspalte im Brett.
|
||||||
|
*
|
||||||
|
* Muss bei jedem Anlegen und bei jedem Statuswechsel gesetzt werden – sonst
|
||||||
|
* stehen alle auf 0 und das Brett zeigt eine Reihenfolge, die sich bei jedem
|
||||||
|
* Laden anders ergeben kann.
|
||||||
|
*/
|
||||||
|
export async function nextProjectPosition(
|
||||||
|
ownerId: string,
|
||||||
|
status: ProjectStatus,
|
||||||
|
): Promise<number> {
|
||||||
|
const last = await prisma.project.findFirst({
|
||||||
|
where: { ownerId, status },
|
||||||
|
orderBy: { position: "desc" },
|
||||||
|
select: { position: true },
|
||||||
|
});
|
||||||
|
return (last?.position ?? -1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verbindungsfelder, die an den Browser gehen duerfen.
|
* Verbindungsfelder, die an den Browser gehen duerfen.
|
||||||
* tokenCipher ist bewusst nicht dabei.
|
* tokenCipher ist bewusst nicht dabei.
|
||||||
|
|||||||
@@ -35,6 +35,41 @@ export const projectCreateSchema = z.object({
|
|||||||
|
|
||||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Reihenfolge im Brett. Uebergeben wird je betroffener Spalte die
|
||||||
|
* vollstaendige Liste in ihrer neuen Reihenfolge – daraus ergeben sich Status
|
||||||
|
* und Position eindeutig, und der Aufruf laesst sich gefahrlos wiederholen.
|
||||||
|
*/
|
||||||
|
export const projectReorderSchema = z.object({
|
||||||
|
columns: z
|
||||||
|
.record(z.enum(STATUS), z.array(z.string().min(1)).max(500))
|
||||||
|
.refine((value) => Object.keys(value).length > 0, "Keine Spalte angegeben"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Dieselbe Aenderung fuer mehrere Projekte auf einmal. */
|
||||||
|
export const projectBulkSchema = z
|
||||||
|
.object({
|
||||||
|
ids: z.array(z.string().min(1)).min(1).max(200),
|
||||||
|
action: z.enum(["status", "tags", "favorite", "delete"]),
|
||||||
|
status: z.enum(STATUS).optional(),
|
||||||
|
/** Bei action "tags": ergaenzen oder entfernen. */
|
||||||
|
tags: z.array(z.string().trim().min(1).max(30)).max(12).optional(),
|
||||||
|
tagMode: z.enum(["add", "remove"]).default("add"),
|
||||||
|
favorite: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((value) => value.action !== "status" || value.status !== undefined, {
|
||||||
|
message: "Ohne Zielstatus laesst sich nichts verschieben",
|
||||||
|
path: ["status"],
|
||||||
|
})
|
||||||
|
.refine((value) => value.action !== "tags" || (value.tags?.length ?? 0) > 0, {
|
||||||
|
message: "Ohne Tags gibt es nichts zu aendern",
|
||||||
|
path: ["tags"],
|
||||||
|
})
|
||||||
|
.refine((value) => value.action !== "favorite" || value.favorite !== undefined, {
|
||||||
|
message: "Kein Zielzustand angegeben",
|
||||||
|
path: ["favorite"],
|
||||||
|
});
|
||||||
|
|
||||||
// ── Repositories ─────────────────────────────────────────────
|
// ── Repositories ─────────────────────────────────────────────
|
||||||
|
|
||||||
export const repositoryCreateSchema = z.object({
|
export const repositoryCreateSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user