fix(admin): Feinschliff nach Live-Prüfung des neuen Admin-Panels

- Website-Theme (next-themes) überschrieb dark/color-scheme auf <html>
  nachträglich → dunkler Scrollbalken, schwarzer Schalter im Hellmodus;
  Admin-Wahl wird jetzt per MutationObserver durchgesetzt
- Trend bei kleiner Vergleichsbasis absolut (+15 statt +1500 %)
- Diagramm: ganzzahlige y-Achse, keine überlappenden Datumslabels, breiter
- Suche: Tierart als Unterzeile, Kürzel „/“ nur bei leerem Feld
- Kopfzeile: Platzhalter statt „Admin“, solange der Name lädt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-05 07:49:56 +02:00
1 parent c4fb9aefb7
commit e42831cfa9
9 files changed
+89 -30

No files matched your search

+4 -2
View File
@@ -122,7 +122,9 @@ describe("adminOverview", () => {
describe("search", () => {
it("finds pets by name or nickname and owners via Clerk", async () => {
prisma.pet.findMany.mockResolvedValue([{ id: "p1", name: "Berli", nickname: null, avatarKey: null }]);
prisma.pet.findMany.mockResolvedValue([
{ id: "p1", name: "Berli", nickname: null, avatarKey: null, species: { name: "Cat", translations: [{ name: "Katze" }] } },
]);
mockGetUserList.mockResolvedValue({
data: [{ id: "user_1", firstName: "Anna", lastName: null, primaryEmailAddress: { emailAddress: "anna@example.com" }, emailAddresses: [] }],
});
@@ -134,7 +136,7 @@ describe("adminOverview", () => {
);
expect(mockGetUserList).toHaveBeenCalledWith({ query: "berl", limit: 6 });
expect(result).toEqual({
pets: [{ id: "p1", name: "Berli", nickname: null, avatarUrl: null }],
pets: [{ id: "p1", name: "Berli", nickname: null, species: "Katze", avatarUrl: null }],
owners: [{ id: "user_1", name: "Anna", email: "anna@example.com" }],
});
});
+2 -1
View File
@@ -61,8 +61,9 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
<div
id={ADMIN_ROOT_ID}
className={`admin-theme ${theme === "dark" ? "dark" : ""} flex min-h-screen bg-background text-foreground`}
style={{ colorScheme: theme }}
>
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col gap-5 overflow-y-auto border-r border-sidebar-border bg-sidebar px-3 py-5 md:flex">
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 flex-col gap-5 overflow-y-auto [scrollbar-width:thin] border-r border-sidebar-border bg-sidebar px-3 py-5 md:flex">
<Link href={base} className="flex items-center gap-2.5 px-3">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary text-primary-foreground">
<PawPrint className="h-4 w-4" aria-hidden />
+7 -5
View File
@@ -57,7 +57,7 @@ export function AdminSearch({ base }: { base: string }) {
key: `pet:${p.id}`,
href: `${base}/pets?pet=${encodeURIComponent(p.id)}`,
title: p.nickname ? `${p.name} (${p.nickname})` : p.name,
subtitle: null,
subtitle: p.species,
kind: "PET" as const,
})),
...data.owners.map((o) => ({
@@ -150,11 +150,13 @@ export function AdminSearch({ base }: { base: string }) {
aria-expanded={showPanel}
aria-controls={listId}
aria-activedescendant={showPanel && results.length > 0 ? `${listId}-${activeIndex}` : undefined}
className="h-9 w-full rounded-lg border border-input bg-card pl-9 pr-10 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
className="h-9 w-full rounded-lg border border-input bg-card pl-9 pr-9 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
/>
<kbd className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 rounded border border-border px-1.5 text-[11px] text-muted-foreground sm:block">
/
</kbd>
{query === "" && (
<kbd className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 rounded border border-border px-1.5 text-[11px] text-muted-foreground sm:block">
/
</kbd>
)}
{showPanel && (
<div
id={listId}
+29 -7
View File
@@ -1,33 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { ADMIN_ROOT_ID, ADMIN_THEME_COOKIE, type AdminTheme } from "@/components/admin/admin-theme";
const COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365;
/** Setzt Admin-Klassen und color-scheme auf <html> und den Admin-Container (nur wenn nötig). */
function applyTheme(theme: AdminTheme) {
const isDark = theme === "dark";
const html = document.documentElement;
if (!html.classList.contains("admin-theme")) html.classList.add("admin-theme");
if (html.classList.contains("dark") !== isDark) html.classList.toggle("dark", isDark);
if (html.style.colorScheme !== theme) html.style.colorScheme = theme;
const root = document.getElementById(ADMIN_ROOT_ID);
if (root) {
root.classList.toggle("dark", isDark);
root.style.colorScheme = theme;
}
}
/**
* Hell/Dunkel nur fürs Admin-Panel (unabhängig vom Website-Theme). Die Wahl liegt
* im Cookie, damit das Layout schon serverseitig richtig rendert. Solange das
* Panel offen ist, bekommt auch <html> die Admin-Klassen — sonst würden Dialoge,
* Menüs und Selects (Portale direkt im <body>) im Website-Theme erscheinen.
* next-themes setzt Klasse und color-scheme der Website erst nach diesem Effekt
* (Eltern-Effekte laufen nach Kind-Effekten) und bei jedem Wechsel neu — deshalb
* stellt ein MutationObserver die Admin-Wahl jedes Mal wieder her.
*/
export function AdminThemeToggle({ initialTheme }: { initialTheme: AdminTheme }) {
const [theme, setTheme] = useState<AdminTheme>(initialTheme);
const themeRef = useRef(theme);
useEffect(() => {
const html = document.documentElement;
const previous = html.className;
html.classList.add("admin-theme");
const previousClass = html.className;
const previousScheme = html.style.colorScheme;
applyTheme(themeRef.current);
const observer = new MutationObserver(() => applyTheme(themeRef.current));
observer.observe(html, { attributes: true, attributeFilter: ["class", "style"] });
return () => {
html.className = previous;
observer.disconnect();
html.className = previousClass;
html.style.colorScheme = previousScheme;
};
}, []);
useEffect(() => {
const isDark = theme === "dark";
document.documentElement.classList.toggle("dark", isDark);
document.getElementById(ADMIN_ROOT_ID)?.classList.toggle("dark", isDark);
themeRef.current = theme;
applyTheme(theme);
}, [theme]);
const toggle = () => {
+7 -3
View File
@@ -31,7 +31,7 @@ function initials(name: string) {
/** Kopfzeile: Menü (mobil), Breadcrumb, globale Suche, Hell/Dunkel, eigener Name + Rolle. */
export function AdminTopbar({ base, groups, roleLabel, theme }: AdminTopbarProps) {
const pathname = usePathname();
const { user } = useUser();
const { user, isLoaded } = useUser();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const current = groups.flatMap((g) => g.items).find((item) => isAdminNavActive(item.href, base, pathname));
const name = user?.firstName || user?.fullName || "Admin";
@@ -80,10 +80,14 @@ export function AdminTopbar({ base, groups, roleLabel, theme }: AdminTopbarProps
<AdminThemeToggle initialTheme={theme} />
<div className="hidden items-center gap-2.5 border-l border-border pl-3 lg:flex">
<span className="grid h-8 w-8 place-items-center rounded-full bg-accent text-xs font-bold text-accent-foreground" aria-hidden>
{initials(name)}
{isLoaded ? initials(name) : ""}
</span>
<span className="leading-tight">
<span className="block text-sm font-semibold text-foreground">{name}</span>
{isLoaded ? (
<span className="block text-sm font-semibold text-foreground">{name}</span>
) : (
<span className="block h-4 w-16 animate-pulse rounded bg-muted" aria-hidden />
)}
<span className="block text-xs text-muted-foreground">{roleLabel}</span>
</span>
</div>
+10 -6
View File
@@ -16,8 +16,8 @@ interface TimeSeriesChartProps {
valueLabel: string;
}
const W = 640;
const H = 220;
const W = 900;
const H = 240;
const L = 40; // left axis gutter
const R = 12;
const T = 16;
@@ -50,7 +50,10 @@ export function TimeSeriesChart({
return <div className="h-[220px] flex items-center justify-center text-sm text-muted-foreground">Noch keine Daten</div>;
}
const maxV = Math.max(...data.map((d) => d.value), 1);
// Ganzzahlige, gleichmäßige Achsenwerte (keine „3, 2, 2, 1, 0“ bei kleinen Zahlen)
const yTicks = 4;
const tickStep = Math.max(1, Math.ceil(Math.max(...data.map((d) => d.value), 1) / yTicks));
const maxV = tickStep * yTicks;
const cW = W - L - R;
const cH = H - T - B;
@@ -58,13 +61,14 @@ export function TimeSeriesChart({
data.length === 1 ? L + cW / 2 : L + (i / (data.length - 1)) * cW;
const yOf = (v: number) => T + cH - (v / maxV) * cH;
const yTicks = 4;
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) => (maxV * i) / yTicks);
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) => tickStep * i);
const maxLabels = variant === "bar" ? Math.min(data.length, 8) : 6;
const step = Math.max(1, Math.ceil(data.length / maxLabels));
// Letzter Punkt immer beschriftet; ein regulärer Label-Punkt zu dicht davor entfällt (sonst Überlappung)
const lastIdx = data.length - 1;
const xLabelIndices = new Set(
data.map((_, i) => i).filter((i) => i % step === 0 || i === data.length - 1)
data.map((_, i) => i).filter((i) => i === lastIdx || (i % step === 0 && lastIdx - i >= Math.ceil(step / 2)))
);
const barWidth = variant === "bar" ? Math.max((cW / data.length) * 0.6, 2) : 0;
+7 -2
View File
@@ -41,7 +41,12 @@ describe("formatTrend", () => {
expect(formatTrend(15, 10)).toEqual({ label: "+50 %", direction: "up" });
expect(formatTrend(9, 10)).toEqual({ label: "−10 %", direction: "down" });
expect(formatTrend(10, 10)).toEqual({ label: "±0 %", direction: "flat" });
expect(formatTrend(3, 0)).toEqual({ label: "neu", direction: "up" });
expect(formatTrend(0, 0)).toEqual({ label: "±0 %", direction: "flat" });
});
it("uses absolute numbers when the comparison base is tiny", () => {
expect(formatTrend(16, 1)).toEqual({ label: "+15", direction: "up" });
expect(formatTrend(3, 0)).toEqual({ label: "+3", direction: "up" });
expect(formatTrend(1, 4)).toEqual({ label: "−3", direction: "down" });
expect(formatTrend(0, 0)).toEqual({ label: "±0", direction: "flat" });
});
});
+9 -2
View File
@@ -45,9 +45,16 @@ export function greetingFor(now: Date = new Date()): string {
return "Guten Abend";
}
/** Veränderung gegenüber dem Vergleichszeitraum, z. B. „+25 %“, „−10 %“, „neu“ (Vorher 0). */
/** Unter diesem Vergleichswert sind Prozente irreführend (1 → 16 = „+1500 %“) — dann absolut. */
const MIN_BASE_FOR_PERCENT = 5;
/** Veränderung gegenüber dem Vergleichszeitraum: „+25 %“, „−10 %“, bei kleiner Basis absolut („+15“). */
export function formatTrend(current: number, previous: number): { label: string; direction: "up" | "down" | "flat" } {
if (previous === 0) return current === 0 ? { label: "±0 %", direction: "flat" } : { label: "neu", direction: "up" };
if (previous < MIN_BASE_FOR_PERCENT) {
const diff = current - previous;
if (diff === 0) return { label: "±0", direction: "flat" };
return { label: `${diff > 0 ? "+" : "−"}${Math.abs(diff)}`, direction: diff > 0 ? "up" : "down" };
}
const pct = Math.round(((current - previous) / previous) * 100);
if (pct === 0) return { label: "±0 %", direction: "flat" };
return { label: `${pct > 0 ? "+" : "−"}${Math.abs(pct)} %`, direction: pct > 0 ? "up" : "down" };
+14 -2
View File
@@ -183,13 +183,25 @@ export const adminOverviewRouter = router({
},
orderBy: { createdAt: "desc" },
take: SEARCH_LIMIT,
select: { id: true, name: true, nickname: true, avatarKey: true },
select: {
id: true,
name: true,
nickname: true,
avatarKey: true,
species: { select: { name: true, translations: { where: { locale: "de" }, select: { name: true } } } },
},
}),
clerk.users.getUserList({ query: input.query, limit: SEARCH_LIMIT }),
]);
return {
pets: pets.map((p) => ({ id: p.id, name: p.name, nickname: p.nickname, avatarUrl: p.avatarKey ? getAvatarUrl(p.avatarKey) : null })),
pets: pets.map((p) => ({
id: p.id,
name: p.name,
nickname: p.nickname,
species: p.species.translations[0]?.name ?? p.species.name,
avatarUrl: p.avatarKey ? getAvatarUrl(p.avatarKey) : null,
})),
owners: users.data.map((u) => ({
id: u.id,
name: [u.firstName, u.lastName].filter(Boolean).join(" ") || null,