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:
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||
import type { ProjectStatus } from "@prisma/client";
|
||||
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatusSelectProps {
|
||||
projectId: string;
|
||||
status: ProjectStatus;
|
||||
/** compact: nur Punkt + Kurzlabel, fuer die Karten im Board. */
|
||||
variant?: "compact" | "full";
|
||||
onChanged?: (status: ProjectStatus) => void;
|
||||
}
|
||||
|
||||
export function StatusSelect({
|
||||
projectId,
|
||||
status,
|
||||
variant = "full",
|
||||
onChanged,
|
||||
}: StatusSelectProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [current, setCurrent] = useState<ProjectStatus>(status);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const meta = statusOf(current);
|
||||
|
||||
async function select(next: ProjectStatus) {
|
||||
setOpen(false);
|
||||
if (next === current) return;
|
||||
|
||||
const previous = current;
|
||||
setCurrent(next); // optimistisch
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
onChanged?.(next);
|
||||
startTransition(() => router.refresh());
|
||||
} catch {
|
||||
setCurrent(previous); // zurueckrollen
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full font-medium ring-1 ring-inset transition-all hover:brightness-125",
|
||||
meta.className,
|
||||
variant === "compact" ? "px-2.5 py-1 text-[11px]" : "px-3 py-1.5 text-xs",
|
||||
)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{saving || pending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
className="accent-dot h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: meta.color, color: meta.color }}
|
||||
/>
|
||||
)}
|
||||
{variant === "compact" ? meta.short : meta.label}
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Auswahl schliessen"
|
||||
className="fixed inset-0 z-30 cursor-default"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="listbox"
|
||||
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5"
|
||||
>
|
||||
{STATUS_ORDER.map((value) => {
|
||||
const option = STATUS_META[value];
|
||||
const active = value === current;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void select(value);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
active ? "bg-white/8" : "hover:bg-white/5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-100">
|
||||
{option.label}
|
||||
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user