feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche

- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr
- Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive
  Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte
  offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue
  Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung)
- Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur,
  tolerante Wortsuche, Ziele nur aus fester Liste
- Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl
  "Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen"
  mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln
- Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton
  #a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen,
  2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im
  selben Ton, Rang der Top-Produkte als Wabe
- Einheitliche Admin-Details zentral: groessere Ueberschriften,
  Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile

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 13:45:24 +02:00
1 parent 15ddceaa39
commit c95c713717
11 files changed
+762 -133

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { HexMark } from "@/components/honeycomb";
export interface PaletteEntry {
href: string;
label: string;
section: "pages" | "actions";
}
/**
* Schnellsuche fuer den Admin-Bereich (Strg/Cmd + K): jede Seite und die
* haeufigsten Aktionen sind zwei Tastendruecke entfernt, statt durch die
* Seitenleiste zu scrollen. Pfeiltasten waehlen, Enter springt, Esc schliesst.
*
* Sucht tolerant: jedes getippte Wort muss irgendwo im Eintrag vorkommen
* ("neu gut" findet "Neuen Gutschein anlegen"). Ziele sind ausschliesslich
* die fest hinterlegten Eintraege, nie die Eingabe selbst.
*/
export function AdminCommandPalette({
entries,
labels,
open,
onOpenChange,
}: {
entries: PaletteEntry[];
labels: { placeholder: string; noResults: string; pages: string; actions: string; dialog: string };
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Globale Tastenkombination — auch wenn der Fokus gerade woanders liegt.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
onOpenChange(!open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
const results = useMemo(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return entries;
return entries.filter((entry) => words.every((word) => entry.label.toLowerCase().includes(word)));
}, [entries, query]);
function close() {
onOpenChange(false);
setQuery("");
setActive(0);
}
function go(entry: PaletteEntry | undefined) {
if (!entry) return;
close();
router.push(entry.href);
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape") {
close();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setActive((i) => Math.min(results.length - 1, i + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => Math.max(0, i - 1));
} else if (event.key === "Enter") {
event.preventDefault();
go(results[active]);
}
}
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-[65] flex items-start justify-center bg-forest/40 px-4 pt-[15vh] backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<motion.div
role="dialog"
aria-modal="true"
aria-label={labels.dialog}
initial={{ opacity: 0, y: -12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98, transition: { duration: 0.12 } }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
onAnimationComplete={() => inputRef.current?.focus()}
className="w-full max-w-lg overflow-hidden border border-ink/10 bg-background shadow-[0_40px_80px_-30px_rgba(36,53,43,0.6)]"
>
<div className="flex items-center gap-3 border-b border-ink/10 px-5">
<svg viewBox="0 0 24 24" className="h-4 w-4 flex-shrink-0 text-ink/45" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
autoFocus
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
placeholder={labels.placeholder}
role="combobox"
aria-expanded="true"
aria-controls="admin-palette-results"
aria-activedescendant={results[active] ? `palette-${active}` : undefined}
className="h-14 w-full bg-transparent text-base focus:outline-none focus-visible:outline-none focus:shadow-none"
/>
<kbd className="flex-shrink-0 border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Esc</kbd>
</div>
<ul id="admin-palette-results" role="listbox" className="max-h-80 overflow-y-auto py-2">
{results.length === 0 ? (
<li className="px-5 py-6 text-center text-sm text-ink/60">{labels.noResults}</li>
) : null}
{results.map((entry, i) => {
const showSection = i === 0 || results[i - 1].section !== entry.section;
return (
<li key={entry.href + entry.label} role="presentation">
{showSection ? (
<p className="px-5 pb-1 pt-3 text-[10px] tracking-wide-caps text-ink/45">
{entry.section === "pages" ? labels.pages : labels.actions}
</p>
) : null}
<button
id={`palette-${i}`}
type="button"
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => go(entry)}
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
i === active ? "bg-honey/10 text-forest" : "text-ink/75"
}`}
>
<HexMark
filled={i === active}
className={`h-2.5 w-2.5 flex-shrink-0 ${i === active ? "text-honey-dark" : "text-ink/25"}`}
/>
<span className="flex-1">{entry.label}</span>
{i === active ? (
<kbd className="border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Enter</kbd>
) : null}
</button>
</li>
);
})}
</ul>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}