feat(admin): Listen mit Status-Badges, Leerzustaenden und klaren Aktionen

Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit
Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus,
wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und
naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich-
Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft).

Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine
(neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf
admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und
Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 20:51:50 +02:00
1 parent 95b4b5953d
commit a09057d960
12 files changed
+440 -300

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { LinkButton } from "@/components/ui";
import { HexMark } from "@/components/honeycomb";
// Gemeinsame Bausteine der Admin-Listen (Kopfzeile, Suche, Status-Badge,
// Leerzustand, Zeilenaktionen). Operate-Modus: gleiche Stelle, gleiche Form
// auf jeder Liste, damit man beim Wechseln nicht neu suchen muss.
export function AdminPageHeader({
title,
intro,
action,
}: {
title: string;
intro?: ReactNode;
/** Hauptaktion der Seite, z.B. "Neuer Gutschein". */
action?: { href: string; label: string };
}) {
return (
<div className="mb-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl text-forest">{title}</h1>
{action ? (
<LinkButton href={action.href} className="px-5 py-2.5">
{action.label}
</LinkButton>
) : null}
</div>
{intro ? <p className="mt-2 max-w-2xl text-sm text-ink/70">{intro}</p> : null}
</div>
);
}
export function AdminSearchForm({
query,
label,
placeholder,
submitLabel,
resetHref,
resetLabel,
}: {
query: string;
label: string;
placeholder: string;
submitLabel: string;
resetHref: string;
resetLabel: string;
}) {
return (
<form method="GET" role="search" className="mb-6 flex flex-wrap items-end gap-3">
<label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{label}</span>
<input
type="search"
name="q"
defaultValue={query}
placeholder={placeholder}
className="w-64 max-w-full border border-ink/20 bg-background px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{submitLabel}
</button>
{query ? (
<Link href={resetHref} className="link-draw py-2 text-sm text-ink/65">
{resetLabel}
</Link>
) : null}
</form>
);
}
export type AdminBadgeTone = "positive" | "pending" | "neutral" | "danger";
const BADGE_TONES: Record<AdminBadgeTone, string> = {
positive: "border-forest/40 bg-forest/5 text-forest",
pending: "border-honey/40 bg-honey/5 text-honey-dark",
neutral: "border-ink/20 text-ink/70",
danger: "border-red-200 bg-red-50 text-red-700",
};
/** Status auf einen Blick — gleiche Form wie der Bestellstatus (OrderStatusBadge). */
export function AdminBadge({
tone,
pulse = false,
children,
}: {
tone: AdminBadgeTone;
/** Wartet auf eine Handlung (z.B. Freigabe): Punkt pulsiert sanft. */
pulse?: boolean;
children: ReactNode;
}) {
return (
<span
className={`inline-flex items-center gap-2 whitespace-nowrap border px-2.5 py-1 text-xs tracking-wide-caps ${BADGE_TONES[tone]}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full bg-current ${pulse ? "motion-pulse animate-[wh-pulse_2s_ease-out_infinite]" : ""}`}
/>
{children}
</span>
);
}
/** Leerzustand statt einer blassen Textzeile: sagt, was fehlt, und bietet den naechsten Schritt an. */
export function AdminEmptyState({
message,
action,
bordered = false,
}: {
message: string;
action?: { href: string; label: string };
/** Eigener Rahmen, wenn der Leerzustand nicht in einer Tabellenkarte steht. */
bordered?: boolean;
}) {
return (
<div
className={`flex flex-col items-center gap-4 px-6 py-12 text-center ${bordered ? "border border-ink/10 bg-background" : ""}`}
>
<span className="relative inline-flex text-honey/60">
<HexMark className="h-10 w-10" filled={false} />
<HexMark className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 text-honey" />
</span>
<p className="max-w-sm text-sm text-ink/70">{message}</p>
{action ? (
<LinkButton href={action.href} variant="secondary" className="px-5 py-2.5">
{action.label}
</LinkButton>
) : null}
</div>
);
}
export function AdminRowActions({ children }: { children: ReactNode }) {
return <div className="flex items-center justify-end gap-4 whitespace-nowrap">{children}</div>;
}
export function AdminActionLink({ href, children }: { href: string; children: ReactNode }) {
return (
<Link href={href} className="link-draw text-forest">
{children}
</Link>
);
}
const ACTION_TONES = {
default: "text-forest",
muted: "text-ink/70",
danger: "text-red-700",
} as const;
/** Absende-Knopf einer Zeilenaktion — sieht aus wie ein Link, ist aber ein Formular-Submit. */
export function AdminActionButton({
tone = "default",
children,
}: {
tone?: keyof typeof ACTION_TONES;
children: ReactNode;
}) {
return (
<button type="submit" className={`link-draw cursor-pointer ${ACTION_TONES[tone]}`}>
{children}
</button>
);
}
/** Grund, warum eine Aktion fehlt — statt den Knopf wortlos wegzulassen. */
export function AdminActionNote({ children }: { children: ReactNode }) {
return <span className="text-xs text-ink/55">{children}</span>;
}