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:
1 parent
15ddceaa39
commit
c95c713717
11 files changed
+762
-133
No files matched your search
+213
-81
@@ -7,27 +7,74 @@ import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Logomark } from "@/components/logomark";
|
||||
import { HexMark, HoneycombPattern } from "@/components/honeycomb";
|
||||
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
|
||||
import { useI18n } from "@/i18n/i18n-provider";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
|
||||
function getAdminNav(t: Dictionary) {
|
||||
/** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
|
||||
export type AdminBadges = Partial<Record<string, number>>;
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gruppiert statt einer langen Liste: 13 Eintraege lesen sich in vier
|
||||
* kurzen Bloecken schneller, und die taegliche Arbeit (Verkauf) steht oben.
|
||||
*/
|
||||
function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavItem[] }[] {
|
||||
return [
|
||||
{ href: "/admin", label: t.adminShell.navDashboard },
|
||||
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
|
||||
{ href: "/admin/retouren", label: t.adminShell.navReturns },
|
||||
{ href: "/admin/produkte", label: t.adminShell.navProducts },
|
||||
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
|
||||
{ href: "/admin/chargen", label: t.adminShell.navBatches },
|
||||
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
|
||||
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
|
||||
{ href: "/admin/versand", label: t.adminShell.navShipping },
|
||||
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
|
||||
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
|
||||
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
||||
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
||||
{ label: null, items: [{ href: "/admin", label: t.adminShell.navDashboard }] },
|
||||
{
|
||||
label: t.adminShell.groupSales,
|
||||
items: [
|
||||
{ href: "/admin/bestellungen", label: t.adminShell.navOrders },
|
||||
{ href: "/admin/retouren", label: t.adminShell.navReturns },
|
||||
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
|
||||
{ href: "/admin/versand", label: t.adminShell.navShipping },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t.adminShell.groupRange,
|
||||
items: [
|
||||
{ href: "/admin/produkte", label: t.adminShell.navProducts },
|
||||
{ href: "/admin/ernten", label: t.adminShell.navHarvests },
|
||||
{ href: "/admin/chargen", label: t.adminShell.navBatches },
|
||||
{ href: "/admin/bewertungen", label: t.adminShell.navReviews },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t.adminShell.groupCustomers,
|
||||
items: [
|
||||
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
|
||||
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t.adminShell.groupSite,
|
||||
items: [
|
||||
{ href: "/admin/inhalte", label: t.adminShell.navContent },
|
||||
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getPaletteEntries(t: Dictionary): PaletteEntry[] {
|
||||
const pages = getAdminNavGroups(t).flatMap((group) =>
|
||||
group.items.map((item) => ({ ...item, section: "pages" as const }))
|
||||
);
|
||||
const actions: PaletteEntry[] = [
|
||||
{ href: "/admin/produkte/neu", label: t.adminShell.actionNewProduct, section: "actions" },
|
||||
{ href: "/admin/gutscheine/neu", label: t.adminShell.actionNewVoucher, section: "actions" },
|
||||
{ href: "/admin/benutzer/neu", label: t.adminShell.actionNewUser, section: "actions" },
|
||||
{ href: "/admin/versand/neu", label: t.adminShell.actionNewShippingTier, section: "actions" },
|
||||
{ href: "/shop", label: t.adminShell.actionViewShop, section: "actions" },
|
||||
];
|
||||
return [...pages, ...actions];
|
||||
}
|
||||
|
||||
function isActiveHref(pathname: string, href: string): boolean {
|
||||
if (href === "/admin") return pathname === "/admin";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
@@ -35,107 +82,186 @@ function isActiveHref(pathname: string, href: string): boolean {
|
||||
|
||||
function NavLinks({
|
||||
pathname,
|
||||
adminNav,
|
||||
groups,
|
||||
badges,
|
||||
onNavigate,
|
||||
waitingLabel,
|
||||
}: {
|
||||
pathname: string;
|
||||
adminNav: ReturnType<typeof getAdminNav>;
|
||||
groups: ReturnType<typeof getAdminNavGroups>;
|
||||
badges: AdminBadges;
|
||||
onNavigate?: () => void;
|
||||
waitingLabel: (count: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{adminNav.map((item) => {
|
||||
const active = isActiveHref(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${
|
||||
active ? "bg-cream/10 text-cream" : "text-cream/70 hover:text-cream hover:bg-cream/5"
|
||||
}`}
|
||||
>
|
||||
{/* Wabe statt farbigem Seitenbalken markiert die aktive Seite. */}
|
||||
<HexMark
|
||||
filled={active}
|
||||
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
||||
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
||||
}`}
|
||||
/>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.label ?? groupIndex} className={groupIndex > 0 ? "mt-5" : ""}>
|
||||
{group.label ? (
|
||||
<p className="px-5 pb-1.5 text-[10px] tracking-wide-caps text-cream/40">{group.label}</p>
|
||||
) : null}
|
||||
{group.items.map((item) => {
|
||||
const active = isActiveHref(pathname, item.href);
|
||||
const count = badges[item.href] ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group relative flex items-center gap-3 px-5 py-2 text-sm transition-colors ${
|
||||
active ? "text-cream" : "text-cream/70 hover:text-cream"
|
||||
}`}
|
||||
>
|
||||
{/* Aktive Seite als weich einblendende Flaeche, die beim
|
||||
Wechsel zur neuen Seite hinueberwandert. */}
|
||||
{active ? (
|
||||
<motion.span
|
||||
layoutId="admin-nav-active"
|
||||
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-cream/10"
|
||||
transition={{ type: "spring", stiffness: 420, damping: 36 }}
|
||||
/>
|
||||
) : null}
|
||||
<HexMark
|
||||
filled={active}
|
||||
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
|
||||
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{count > 0 ? (
|
||||
<span
|
||||
className="min-w-5 rounded-full bg-honey-dark px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
|
||||
title={waitingLabel(count)}
|
||||
>
|
||||
<span aria-hidden="true">{count}</span>
|
||||
<span className="sr-only">{waitingLabel(count)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Wordmark() {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<Link href="/admin" className="flex items-center gap-2">
|
||||
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
|
||||
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; label: string; shortcut: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="mx-3 flex w-[calc(100%-1.5rem)] items-center gap-2 border border-cream/15 px-3 py-2 text-left text-xs text-cream/60 transition-colors hover:border-cream/35 hover:text-cream"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" 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>
|
||||
<span className="flex-1">{label}</span>
|
||||
<kbd className="border border-cream/20 px-1 text-[10px] text-cream/50">{shortcut}</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminShellProps {
|
||||
adminName: string;
|
||||
badges?: AdminBadges;
|
||||
logoutAction: () => void | Promise<void>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar-Layout fuer den Admin-Bereich statt der frueheren einzeiligen,
|
||||
* horizontal scrollenden Navigation (11 Punkte passten auf typischen
|
||||
* Bildschirmbreiten nicht nebeneinander). Ab lg persistente Sidebar links,
|
||||
* darunter ein Vollbild-Overlay-Menue nach demselben Muster wie die mobile
|
||||
* Hauptnavigation in site-chrome.tsx.
|
||||
* Eigener Arbeitsbereich fuer die Imkerei: gruppierte Seitenleiste mit
|
||||
* Zaehlern fuer alles, was gerade wartet, und einer Schnellsuche (Strg/Cmd+K).
|
||||
* Ab lg persistente Seitenleiste links, darunter ein Vollbild-Menue nach
|
||||
* demselben Muster wie die mobile Hauptnavigation in site-chrome.tsx. Die
|
||||
* Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
|
||||
* (components/hide-on-admin.tsx).
|
||||
*/
|
||||
export function AdminShell({ adminName, logoutAction, children }: AdminShellProps) {
|
||||
export function AdminShell({ adminName, badges = {}, logoutAction, children }: AdminShellProps) {
|
||||
const { t } = useI18n();
|
||||
const adminNav = getAdminNav(t);
|
||||
const groups = getAdminNavGroups(t);
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
||||
const paletteLabels = {
|
||||
placeholder: t.adminShell.palettePlaceholder,
|
||||
noResults: t.adminShell.paletteNoResults,
|
||||
pages: t.adminShell.paletteSectionPages,
|
||||
actions: t.adminShell.paletteSectionActions,
|
||||
dialog: t.adminShell.paletteOpen,
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="px-5 py-4 border-t border-cream/10">
|
||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
{t.adminShell.logout}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-cream/40 lg:flex">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
|
||||
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
|
||||
<div className="flex items-center h-16 px-5 border-b border-cream/10">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<nav aria-label={t.adminShell.navAriaLabel} className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5">
|
||||
<NavLinks pathname={pathname} adminNav={adminNav} />
|
||||
</nav>
|
||||
<div className="px-5 py-4 border-t border-cream/10">
|
||||
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
{t.adminShell.logout}
|
||||
</button>
|
||||
</form>
|
||||
<div className="pt-4">
|
||||
<PaletteTrigger
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
label={t.adminShell.paletteOpen}
|
||||
shortcut={t.adminShell.paletteShortcut}
|
||||
/>
|
||||
</div>
|
||||
<nav aria-label={t.adminShell.navAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
|
||||
<NavLinks pathname={pathname} groups={groups} badges={badges} waitingLabel={t.adminShell.badgeWaiting} />
|
||||
</nav>
|
||||
{footer}
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
|
||||
<Wordmark />
|
||||
<button
|
||||
aria-label={t.adminShell.openMenu}
|
||||
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
aria-label={t.adminShell.paletteOpen}
|
||||
className="w-10 h-10 flex items-center justify-center"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t.adminShell.openMenu}
|
||||
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">
|
||||
{children}
|
||||
</main>
|
||||
{/* Kein zweites <main>: das Root-Layout stellt bereits eines. */}
|
||||
<div className="admin-content max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">{children}</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
@@ -159,20 +285,26 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="flex-1 overflow-y-auto py-4">
|
||||
<NavLinks pathname={pathname} adminNav={adminNav} onNavigate={() => setMobileOpen(false)} />
|
||||
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
|
||||
<NavLinks
|
||||
pathname={pathname}
|
||||
groups={groups}
|
||||
badges={badges}
|
||||
waitingLabel={t.adminShell.badgeWaiting}
|
||||
onNavigate={() => setMobileOpen(false)}
|
||||
/>
|
||||
</nav>
|
||||
<div className="px-5 py-4 border-t border-cream/10 flex-shrink-0">
|
||||
<p className="text-xs text-cream/60">{t.adminShell.loggedInAs(adminName)}</p>
|
||||
<form action={logoutAction} className="mt-2">
|
||||
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
|
||||
{t.adminShell.logout}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{footer}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<AdminCommandPalette
|
||||
entries={getPaletteEntries(t)}
|
||||
labels={paletteLabels}
|
||||
open={paletteOpen}
|
||||
onOpenChange={setPaletteOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user