diff --git a/FEATURES.md b/FEATURES.md index d316107..f053c33 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -259,6 +259,19 @@ Blockiert · Erledigt**. vor Beschreibung, Wiederholung, Frist und Labels, damit sich eine Spalte danach überfliegen lässt. Bewusst kein Link: der Weg zum Issue führt über den Dialog (4.6), weil ein `` innerhalb der anklickbaren Karte ungültiges Markup wäre. + +#### Wie viele Karten eine Spalte zeigt + +Eine Spalte zeigt höchstens so viele Karten, wie unter *Admin-Einstellungen → +Aufgabenbrett* eingestellt ist (Voreinstellung 15, `0` schaltet die Begrenzung ab). Was +darüber hinausgeht, steht hinter einem Knopf „*n* weitere anzeigen"; entfaltet wird je +Spalte einzeln. Die Zahl neben der Spaltenüberschrift nennt weiterhin den **vollen** +Bestand — sonst wäre nicht zu erkennen, dass etwas fehlt. + +**Begrenzt wird nur die Anzeige.** Das Umsortieren schickt je Spalte die vollständige +Liste an den Server; würde hier schon gekürzt, fielen die verborgenen Aufgaben beim +nächsten Ziehen aus dem Brett. Die Einstellung ist deshalb bewusst eine Sache der +Darstellung und keine der Abfrage — geladen wird ohnehin das ganze Projekt. - Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen wieder gelöscht. diff --git a/prisma/migrations/20260818230000_task_column_limit/migration.sql b/prisma/migrations/20260818230000_task_column_limit/migration.sql new file mode 100644 index 0000000..c20a4b5 --- /dev/null +++ b/prisma/migrations/20260818230000_task_column_limit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AppSettings" ADD COLUMN "taskColumnLimit" INTEGER NOT NULL DEFAULT 15; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5eaed02..d865db8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -266,6 +266,11 @@ model AppSettings { /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } lastAutoSyncReport Json? + // ── Aufgabenbrett ────────────────────────────────────────── + /// Hoechstzahl Karten je Spalte. Was darueber hinausgeht, kommt hinter + /// einen Knopf – verborgen wird nichts. 0 schaltet die Begrenzung ab. + taskColumnLimit Int @default(15) + // ── Stillstand ───────────────────────────────────────────── /// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. staleAfterDays Int @default(60) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a8654ef..6bf2af3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -19,7 +19,7 @@ export async function generateMetadata(): Promise { export default async function AdminPage() { const admin = await requireAdminPage(); - const [settings, users, eligible, staleListeners] = await Promise.all([ + const [settings, users, eligible, staleListeners, laengsteSpalte] = await Promise.all([ getAppSettings(), prisma.user.findMany({ select: userSafeSelect, @@ -41,6 +41,18 @@ export default async function AdminPage() { prisma.notificationTarget.count({ where: { isActive: true, events: { has: "PROJECT_STALE" } }, }), + // Laengste Spalte ueber alle Projekte. Ohne diese Zahl steht die + // Einstellung im Leeren: eine Grenze von 15 sagt nichts, wenn die + // groesste Spalte im Bestand aus drei Karten besteht. + // + // Wie beim Abgleich bewusst ueber alle Konten – gezaehlt wird nur, es + // wird nichts gelesen und nichts zugeordnet. + prisma.task.groupBy({ + by: ["projectId", "status"], + _count: { _all: true }, + orderBy: { _count: { projectId: "desc" } }, + take: 1, + }), ]); const data: AdminData = { @@ -64,6 +76,10 @@ export default async function AdminPage() { lastCheckAt: settings.lastStaleCheckAt, listeners: staleListeners, }, + tasks: { + columnLimit: settings.taskColumnLimit, + laengsteSpalte: laengsteSpalte[0]?._count._all ?? 0, + }, }; return ( diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index a1c3ecd..2251033 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -38,6 +38,7 @@ export async function PATCH(request: Request) { autoSyncActiveOnly, autoSyncBatchSize, staleAfterDays, + taskColumnLimit, } = parsed.data; // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – @@ -63,6 +64,7 @@ export async function PATCH(request: Request) { ...(staleAfterDays !== undefined ? { staleAfterDays, lastStaleCheckAt: null } : {}), + ...(taskColumnLimit !== undefined ? { taskColumnLimit } : {}), }; const settings = await prisma.appSettings.upsert({ diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index f615808..4977305 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -10,6 +10,7 @@ import { ProjectHeader } from "@/components/ProjectHeader"; import { TaskBoard } from "@/components/tasks/TaskBoard"; import { requireUserPage } from "@/lib/auth/guard"; import { getSessionUser } from "@/lib/auth/session"; +import { getAppSettings } from "@/lib/auth/settings"; import { prisma } from "@/lib/db"; import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects"; import type { ConnectionSafe, ProjectDetail } from "@/types"; @@ -37,7 +38,7 @@ export default async function ProjectPage({ params }: PageProps) { const user = await requireUserPage(); const { id } = await params; - const [project, connections] = await Promise.all([ + const [project, connections, settings] = await Promise.all([ // findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404, // nicht zu einer Fehlermeldung, die seine Existenz bestaetigt. prisma.project.findFirst({ @@ -49,6 +50,7 @@ export default async function ProjectPage({ params }: PageProps) { select: connectionSafeSelect, orderBy: { name: "asc" }, }), + getAppSettings(), ]); if (!project) notFound(); @@ -83,6 +85,7 @@ export default async function ProjectPage({ params }: PageProps) { projectId={detail.id} tasks={detail.tasks} progressFromTasks={detail.progressFromTasks} + columnLimit={settings.taskColumnLimit} /> {/* diff --git a/src/components/admin/AdminManager.tsx b/src/components/admin/AdminManager.tsx index fdeb0d0..b1be23e 100644 --- a/src/components/admin/AdminManager.tsx +++ b/src/components/admin/AdminManager.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { Ban, CheckCircle2, + Columns3, Database, Download, Fingerprint, @@ -74,6 +75,12 @@ export interface AdminData { /** Wie viele Konten ueberhaupt auf Stillstand hoeren. */ listeners: number; }; + tasks: { + /** Karten je Spalte im Aufgabenbrett; 0 = ohne Begrenzung. */ + columnLimit: number; + /** Laengste Spalte ueber alle Projekte – ohne die ist die Zahl blind. */ + laengsteSpalte: number; + }; } type Notice = { ok: boolean; text: string } | null; @@ -195,6 +202,7 @@ export function AdminManager({ data }: { data: AdminData }) { + {/* ── Instanz ─────────────────────────────────────── */}
@@ -1099,3 +1107,128 @@ function UserDialog({ ); } + +const SPALTEN_OPTIONEN = [0, 5, 10, 15, 20, 30, 50]; + +/** + * Wie viele Karten eine Spalte des Aufgabenbretts hoechstens zeigt. + * + * Begrenzt wird nur die Anzeige: der Rest steht hinter einem Knopf, und die + * Reihenfolge wird weiterhin ueber die vollstaendige Spalte gespeichert. + * Eine Aufgabe verschwindet hier also nirgends – sie ist nur einen Klick + * weiter weg. + */ +function TaskLimitSection({ data }: { data: AdminData["tasks"] }) { + const router = useRouter(); + const [limit, setLimit] = useState(data.columnLimit); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + async function save(value: number) { + setBusy(true); + setNotice(null); + try { + const response = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskColumnLimit: value }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen."); + setNotice({ ok: true, text: "Gespeichert." }); + router.refresh(); + } catch (caught) { + setLimit(data.columnLimit); + setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); + } finally { + setBusy(false); + } + } + + const greift = limit > 0 && data.laengsteSpalte > limit; + + return ( +
+
+ + + +
+

+ Aufgabenbrett +

+

+ Wie viele Karten eine Spalte hoechstens zeigt. Was darueber hinausgeht, kommt + hinter einen Knopf – verborgen wird nichts, und die Reihenfolge bleibt + vollstaendig gespeichert. Gilt fuer alle Projekte. +

+
+
+ +
+
+ + +
+ +
+
+
+ Laengste Spalte +
+
+ {data.laengsteSpalte} +
+
+
+
+ Wirkung +
+
+ {limit === 0 ? "aus" : greift ? "greift" : "noch nicht"} +
+
+
+
+ +

+ {limit === 0 + ? "Jede Spalte zeigt alles. Bei langen Listen wird das Brett unuebersichtlich." + : greift + ? "Mindestens eine Spalte ist laenger als die Grenze – dort erscheint der Knopf." + : "Derzeit reicht keine Spalte an die Grenze heran; sichtbar wird die Einstellung erst danach."} +

+ + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} diff --git a/src/components/tasks/TaskBoard.tsx b/src/components/tasks/TaskBoard.tsx index da61210..36b87a6 100644 --- a/src/components/tasks/TaskBoard.tsx +++ b/src/components/tasks/TaskBoard.tsx @@ -18,11 +18,11 @@ import { import { restrictToWindowEdges } from "@dnd-kit/modifiers"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CheckSquare, Loader2, Plus } from "lucide-react"; +import { CheckSquare, ChevronDown, ChevronUp, Loader2, Plus } from "lucide-react"; import type { Task, TaskStatus } from "@prisma/client"; import { TaskCard } from "@/components/tasks/TaskCard"; import { TaskDialog } from "@/components/tasks/TaskDialog"; -import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; +import { TASK_STATUS_META, TASK_STATUS_ORDER, spaltenAnzeige } from "@/lib/tasks"; import { cn } from "@/lib/utils"; type Columns = Record; @@ -48,10 +48,13 @@ export function TaskBoard({ projectId, tasks, progressFromTasks, + columnLimit = 0, }: { projectId: string; tasks: Task[]; progressFromTasks: boolean; + /** Karten je Spalte; 0 = ohne Begrenzung. Kommt aus den Admin-Einstellungen. */ + columnLimit?: number; }) { const router = useRouter(); const [columns, setColumns] = useState(() => group(tasks)); @@ -224,6 +227,7 @@ export function TaskBoard({ key={status} status={status} tasks={columns[status]} + limit={columnLimit} onAdd={() => setCreatingIn(status)} onEdit={setEditing} /> @@ -252,16 +256,24 @@ export function TaskBoard({ function TaskColumn({ status, tasks, + limit, onAdd, onEdit, }: { status: TaskStatus; tasks: Task[]; + limit: number; onAdd: () => void; onEdit: (task: Task) => void; }) { const meta = TASK_STATUS_META[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); + const [entfaltet, setEntfaltet] = useState(false); + + // Nur die Anzeige wird gekuerzt – `tasks` bleibt oben vollstaendig, sonst + // wuerde das Umsortieren die verborgenen Aufgaben aus dem Brett loeschen. + const { sichtbar, verborgen } = spaltenAnzeige(tasks.length, limit, entfaltet); + const gezeigt = tasks.slice(0, sichtbar); return (
{tasks.length}
+ {/* Nur die gezeigten Karten stehen im SortableContext – was nicht im + DOM ist, kann dnd-kit ohnehin nicht treffen. */} task.id)} + items={gezeigt.map((task) => task.id)} strategy={verticalListSortingStrategy} >
- {tasks.map((task) => ( + {gezeigt.map((task) => ( ))} {tasks.length === 0 ? ( @@ -300,6 +314,27 @@ function TaskColumn({
+ {verborgen > 0 || entfaltet ? ( + + ) : null} +