Files
Wabenhain/src/components/admin-shell.tsx
T
Nick SzaunigandClaude Opus 5.5 c95c713717 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]>
2026-09-25 13:45:24 +02:00

311 lines
12 KiB
TypeScript

"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import Link from "next/link";
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";
/** 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 [
{ 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}/`);
}
function NavLinks({
pathname,
groups,
badges,
onNavigate,
waitingLabel,
}: {
pathname: string;
groups: ReturnType<typeof getAdminNavGroups>;
badges: AdminBadges;
onNavigate?: () => void;
waitingLabel: (count: number) => string;
}) {
return (
<>
{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 (
<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>
</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;
}
/**
* 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, badges = {}, logoutAction, children }: AdminShellProps) {
const { t } = useI18n();
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-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>
<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 />
<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>
{/* 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>
{mobileOpen ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="lg:hidden fixed inset-0 z-50 bg-forest text-cream flex flex-col"
>
<div className="flex items-center justify-between h-16 px-5 border-b border-cream/10 flex-shrink-0">
<Wordmark />
<button
aria-label={t.adminShell.closeMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0"
onClick={() => setMobileOpen(false)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<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>
{footer}
</motion.div>
) : null}
</AnimatePresence>
<AdminCommandPalette
entries={getPaletteEntries(t)}
labels={paletteLabels}
open={paletteOpen}
onOpenChange={setPaletteOpen}
/>
</div>
);
}