feat(ui): Dashboard mit Board, Filtern und direktem Statuswechsel

- Fuenf Kennzahlen ueber dem Board: Projektzahl mit Durchschnittsfortschritt,
  Ideen, aktive Entwicklungen, fertige Projekte, verknuepfte Repos
- Volltextsuche ueber Name, Beschreibung, Tags und Repo-Pfad
- Statusfilter als Chips mit Trefferzaehler, mehrfach kombinierbar
- Sortierung nach Aenderung, Anlage, Name, Fortschritt oder Prioritaet;
  Favoriten stehen immer oben
- Zwei Ansichten: freies Raster oder nach Status gruppiert
- Archiv standardmaessig ausgeblendet, per Schalter sichtbar

Die Projektkarte zeigt Fortschritt, Provider, Default-Branch, letzten Tag,
Alter des letzten Commits, Notizzahl und Sync-Fehler. Status und Favorit sind
direkt auf der Karte aenderbar - beides optimistisch, mit Rollback wenn der
Server ablehnt. Die Karte ist ein "stretched link", damit die Schaltflaechen
darin nicht in einem Anker verschachtelt sind.

Der Anlege-Dialog verlangt nur einen Namen; neue Projekte starten als Idee.
Ist eine Git-Verbindung gewaehlt, laesst sich das Repository aus einer
durchsuchbaren Liste uebernehmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 19:34:29 +02:00
co-authored by Claude Opus 5
parent 6fcbc04110
commit 62b5c5bfa6
7 changed files with 1297 additions and 0 deletions
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useMemo, useState } from "react";
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem } from "@/types";
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped";
const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert",
created: "Neueste zuerst",
name: "Name (A–Z)",
progress: "Fortschritt",
priority: "Prioritaet",
};
export function ProjectBoard({
projects,
connections,
}: {
projects: ProjectListItem[];
connections: ConnectionSafe[];
}) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<ProjectStatus[]>([]);
const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const counts = useMemo(() => {
const map = new Map<ProjectStatus, number>();
for (const project of projects) {
map.set(project.status, (map.get(project.status) ?? 0) + 1);
}
return map;
}, [projects]);
const visible = useMemo(() => {
const needle = query.toLowerCase().trim();
const filtered = projects.filter((project) => {
if (!showArchived && project.status === "ARCHIVED" && !statusFilter.includes("ARCHIVED")) {
return false;
}
if (statusFilter.length > 0 && !statusFilter.includes(project.status)) return false;
if (!needle) return true;
return (
project.name.toLowerCase().includes(needle) ||
(project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)) ||
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle)
);
});
const sorted = [...filtered].sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
switch (sort) {
case "name":
return a.name.localeCompare(b.name, "de");
case "progress":
return b.progress - a.progress;
case "priority":
return b.priority - a.priority;
case "created":
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
default:
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
});
return sorted;
}, [projects, query, statusFilter, sort, showArchived]);
const grouped = useMemo(() => {
return STATUS_ORDER.map((status) => ({
status,
items: visible.filter((project) => project.status === status),
})).filter((group) => group.items.length > 0);
}, [visible]);
function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) =>
current.includes(status)
? current.filter((entry) => entry !== status)
: [...current, status],
);
}
const hasFilters = query.length > 0 || statusFilter.length > 0;
return (
<section className="space-y-5">
{/* Steuerleiste */}
<div className="glass flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
className="field pl-9"
placeholder="Projekte, Tags oder Repos durchsuchen…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:text-white"
aria-label="Suche zuruecksetzen"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor="sort">
Sortierung
</label>
<select
id="sort"
className="field w-auto min-w-[11rem]"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}>
{SORT_LABEL[key]}
</option>
))}
</select>
<div className="flex overflow-hidden rounded-xl border border-white/9">
<button
type="button"
onClick={() => setView("grid")}
className={cn(
"p-2 transition-colors",
view === "grid" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Rasteransicht"
title="Rasteransicht"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("grouped")}
className={cn(
"p-2 transition-colors",
view === "grouped" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Nach Status gruppiert"
title="Nach Status gruppiert"
>
<Rows3 className="h-4 w-4" />
</button>
</div>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neues Projekt</span>
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
<ListFilter className="h-3.5 w-3.5" />
Status
</span>
{STATUS_ORDER.map((status) => {
const meta = STATUS_META[status];
const active = statusFilter.includes(status);
const count = counts.get(status) ?? 0;
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-all",
active
? meta.className
: "text-slate-400 ring-white/10 hover:bg-white/5 hover:text-slate-200",
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, opacity: active ? 1 : 0.55 }}
/>
{meta.label}
<span className="tabular-nums text-slate-500">{count}</span>
</button>
);
})}
<label className="ml-auto flex cursor-pointer items-center gap-2 text-xs text-slate-500">
<input
type="checkbox"
checked={showArchived}
onChange={(event) => setShowArchived(event.target.checked)}
className="h-3.5 w-3.5 accent-violet-500"
/>
Archiv einblenden
</label>
{hasFilters ? (
<button
type="button"
onClick={() => {
setQuery("");
setStatusFilter([]);
}}
className="text-xs text-violet-300 underline-offset-2 hover:underline"
>
Filter zuruecksetzen
</button>
) : null}
</div>
</div>
{/* Inhalt */}
{visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
))}
</div>
) : (
<div className="space-y-8">
{grouped.map((group) => {
const meta = STATUS_META[group.status];
return (
<div key={group.status}>
<div className="mb-3 flex items-center gap-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h2>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{group.items.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
))}
</div>
</div>
);
})}
</div>
)}
<ProjectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
connections={connections}
/>
</section>
);
}
function EmptyState({
hasFilters,
onCreate,
}: {
hasFilters: boolean;
onCreate: () => void;
}) {
return (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-20 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<Plus className="h-6 w-6" />
</span>
<div>
<h3 className="text-lg font-semibold text-white">
{hasFilters ? "Keine Treffer" : "Noch keine Projekte"}
</h3>
<p className="mt-1 max-w-md text-sm text-slate-400">
{hasFilters
? "Kein Projekt passt zu Suche und Filtern. Filter anpassen oder zuruecksetzen."
: "Leg deine erste Idee an – Beschreibung, Repo und Status kannst du jederzeit nachziehen."}
</p>
</div>
{!hasFilters ? (
<button type="button" className="btn btn-primary" onClick={onCreate}>
<Plus className="h-4 w-4" />
Erstes Projekt anlegen
</button>
) : null}
</div>
);
}