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:
1 parent
c4fb9aefb7
commit
e42831cfa9
9 files changed
+89
-30
No files matched your search
@@ -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" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user