-
+
{order.orderNumber}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 8e443c0..9577eda 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { noticeUrl } from "@/lib/notice";
import { getCurrentAdmin } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell";
import { getLocale } from "@/i18n/locale";
@@ -42,9 +43,28 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
redirect(noticeUrl("admin-2fa"));
}
+ // Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der
+ // Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
+ // abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
+ // unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
+ const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
+ prisma.order.count({
+ where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
+ }),
+ prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
+ prisma.review.count({ where: { isApproved: false } }),
+ prisma.businessInquiry.count({ where: { status: "NEU" } }),
+ ]);
+
return (
{children}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 640f1ac..efa5df7 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -5,6 +5,8 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { checkUploadFiles } from "@/lib/upload-health";
import { checkUploadVolume } from "@/lib/uploads";
+import { getCurrentAdmin } from "@/lib/auth";
+import { HexMark } from "@/components/honeycomb";
const DASHBOARD_ICON_PROPS = {
width: 20,
@@ -99,7 +101,8 @@ function daysAgo(n: number): Date {
}
export default async function AdminDashboardPage() {
- const t = getDictionary(await getLocale());
+ const locale = await getLocale();
+ const t = getDictionary(locale);
const [
revenueToday,
revenueMonth,
@@ -252,6 +255,39 @@ export default async function AdminDashboardPage() {
const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
+ const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
+ getCurrentAdmin(),
+ prisma.order.count({
+ where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
+ }),
+ prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
+ prisma.review.count({ where: { isApproved: false } }),
+ prisma.businessInquiry.count({ where: { status: "NEU" } }),
+ ]);
+ const toDo = [
+ { count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
+ { count: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) },
+ { count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) },
+ { count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) },
+ ].filter((item) => item.count > 0);
+
+ // Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
+ const berlinHour = Number(
+ new Intl.DateTimeFormat("de-DE", { hour: "numeric", hour12: false, timeZone: "Europe/Berlin" }).format(new Date())
+ );
+ const dayPart = berlinHour < 11 ? "morning" : berlinHour < 18 ? "day" : "evening";
+ const todayLine = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ timeZone: "Europe/Berlin",
+ }).format(new Date());
+
+ // Vergleich mit gestern fuer die Leitzahl (aus dem ohnehin geladenen Verlauf).
+ const todayCents = revenueTrend[revenueTrend.length - 1]?.[1] ?? 0;
+ const yesterdayCents = revenueTrend[revenueTrend.length - 2]?.[1] ?? 0;
+ const deltaCents = todayCents - yesterdayCents;
+
// Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all:
// schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar
// nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen.
@@ -262,7 +298,13 @@ export default async function AdminDashboardPage() {
return (
-
{t.adminDashboardPage.title}
+ {/* Begruessung statt nackter Ueberschrift: der Tag beginnt hier. */}
+
+
{todayLine}
+
+ {t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}
+
+
{/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
@@ -301,44 +343,131 @@ export default async function AdminDashboardPage() {
) : null}
) : null}
-
- {stats.map((stat) => {
- const Icon = statIcons[stat.key];
- const primary = stat.key === "revenueToday";
- return (
-
-
-
-
+
+ {/* Die eine Leitzahl der Ansicht: Umsatz heute (Grundschrift,
+ proportionale Ziffern, siehe dataviz-Skill). */}
+
+ {/* Mini-Verlauf der letzten Tage: zeigt ohne Achsen, ob es
+ aufwaerts geht — die Details stehen im Diagramm weiter unten. */}
+
+ `${i * 10},${38 - (cents / maxDailyRevenue) * 34}`)
+ .join(" ")}
+ />
+
+
+ {t.adminDashboardPage.todayRevenueLabel}
+ {formatPrice(revenueToday._sum.totalCents ?? 0)}
+ {todayCents !== 0 || yesterdayCents !== 0 ? (
+ = 0 ? "text-honey-light" : "text-cream/70"}`}>
+ {t.adminDashboardPage.vsYesterday(formatPrice(Math.abs(deltaCents)), deltaCents >= 0)}
+
+ ) : null}
+
+ {t.adminDashboardPage.monthRevenueLabel}{" "}
+ {formatPrice(revenueMonth._sum.totalCents ?? 0)}
+
+
+
+ {/* Was jetzt ansteht — direkt anklickbar. Wartet nichts, gibt es eine
+ kleine Belohnung statt einer leeren Liste. */}
+
+ {t.adminDashboardPage.toDoHeading}
+ {toDo.length > 0 ? (
+
+ {toDo.map((item) => (
+
+
+
+ {item.count}
+
+ {item.label}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
-
{statLabels[stat.key]}
-
{stat.value}
+
{t.adminDashboardPage.allDoneTitle}
+
{t.adminDashboardPage.allDoneText}
- );
- })}
+ )}
+
+
+
+
+ {stats
+ .filter((stat) => stat.key !== "revenueToday" && stat.key !== "revenueMonth")
+ .map((stat) => {
+ const Icon = statIcons[stat.key];
+ return (
+
+
+
+
+
{statLabels[stat.key]}
+
{stat.value}
+
+ );
+ })}
-
-
{t.adminDashboardPage.topProductsHeading}
+
+
{t.adminDashboardPage.topProductsHeading}
{topProducts.length === 0 ? (
{t.adminDashboardPage.noSalesYet}
) : (
{topProducts.map((product, i) => (
-
- {i + 1}. {product.name}{" "}
+
+
+
+ {i + 1}
+
+ {product.name}{" "}
{t.adminDashboardPage.unitsSuffix(product.quantity)}
{formatPrice(product.revenueCents)}
@@ -348,8 +477,8 @@ export default async function AdminDashboardPage() {
)}
-
-
+
+
{t.adminDashboardPage.revenueByCategoryHeading}
{revenueByCategory.length === 0 ? (
@@ -364,9 +493,10 @@ export default async function AdminDashboardPage() {
{formatPrice(totals.revenueCents)}
-
+ {/* Meter: Spur ist eine hellere Stufe desselben Tons. */}
+
@@ -378,23 +508,38 @@ export default async function AdminDashboardPage() {
-
-
+
+
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
-
- {revenueTrend.map(([day, revenueCents]) => (
-
- ))}
+ {/* Balken nach dataviz-Skill: duenn, 4px gerundete Kappe an der
+ Grundlinie verankert, 2px Luecke, gepruefter Honigton
+ (#a0722f, >=3:1 gegen die Flaeche), Tooltip je Balken mit
+ grosszuegiger Trefferflaeche. */}
+
+ {revenueTrend.map(([day, revenueCents], i) => {
+ const isToday = i === revenueTrend.length - 1;
+ const label = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
+ day: "numeric",
+ month: "short",
+ }).format(new Date(day));
+ return (
+
+
+
+ {t.adminDashboardPage.chartTooltip(label, formatPrice(revenueCents))}
+
+
+ );
+ })}
{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}
@@ -402,8 +547,8 @@ export default async function AdminDashboardPage() {
-
-
{t.adminDashboardPage.topVouchersHeading}
+
+
{t.adminDashboardPage.topVouchersHeading}
{topDiscountCodes.length === 0 ? (
{t.adminDashboardPage.noVouchersYet}
) : (
diff --git a/src/app/globals.css b/src/app/globals.css
index 7ded2aa..ac0d8c2 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -19,6 +19,9 @@
--color-background: #faf8f3;
--color-ink: #242424;
--color-sage: #788774;
+ /* Diagrammton, per dataviz-Validator geprueft: Helligkeitsband, Chroma
+ und >=3:1 gegen die helle Flaeche (#b88a44 erreichte nur 2,93:1). */
+ --color-chart-honey: #a0722f;
}
@theme inline {
@@ -301,3 +304,53 @@ textarea::placeholder {
animation: none !important;
}
}
+
+/* ---------------------------------------------------------------------------
+ Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details
+ fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln.
+ Bewusst ausserhalb der Tailwind-Ebenen, damit es die kleinen Standard-
+ Groessen der Seiten ueberstimmt.
+ ------------------------------------------------------------------------- */
+.admin-content h1 {
+ font-size: clamp(2rem, 2.6vw, 2.75rem);
+ line-height: 1.1;
+ letter-spacing: -0.01em;
+}
+
+.admin-content table {
+ font-variant-numeric: tabular-nums;
+}
+
+/* Tabellenkopf als ruhiges Etikett, bleibt beim Scrollen langer Listen oben. */
+.admin-content thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: color-mix(in oklab, var(--color-cream) 70%, var(--color-background));
+ font-size: 0.68rem;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: color-mix(in oklab, var(--color-ink) 60%, transparent);
+}
+
+.admin-content tbody tr {
+ transition: background-color 150ms var(--ease-arrive);
+}
+
+/* Auswahlfelder mit eigenem Pfeil statt des grauen Browser-Standards. */
+.admin-content select {
+ appearance: none;
+ padding-right: 2rem;
+ background-image: url("data:image/svg+xml;utf8,
");
+ background-repeat: no-repeat;
+ background-position: right 0.6rem center;
+ background-size: 0.8rem;
+ cursor: pointer;
+}
+
+.admin-content select:hover,
+.admin-content input:not([type="checkbox"]):not([type="radio"]):hover,
+.admin-content textarea:hover {
+ border-color: color-mix(in oklab, var(--color-ink) 40%, transparent);
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 65b8463..b0d6abf 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -5,6 +5,7 @@ import { Header, CartDrawer } from "@/components/site-chrome";
import { Footer } from "@/components/site-footer";
import { CookieConsentBanner } from "@/components/cookie-consent";
import { FlashToast } from "@/components/flash-toast";
+import { HideOnAdmin } from "@/components/hide-on-admin";
import { SubmitFeedback } from "@/components/submit-feedback";
import { readFlash } from "@/lib/flash";
import { getSiteUrl } from "@/lib/url";
@@ -129,11 +130,15 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
>
{dict.nav.skipToContent}
-
+
+
+
{children}
-
+
+
+
diff --git a/src/components/admin-command-palette.tsx b/src/components/admin-command-palette.tsx
new file mode 100644
index 0000000..86388b6
--- /dev/null
+++ b/src/components/admin-command-palette.tsx
@@ -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
(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) {
+ 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 (
+
+ {open ? (
+ {
+ if (event.target === event.currentTarget) close();
+ }}
+ >
+ 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)]"
+ >
+
+
+
+
+
+
{
+ 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"
+ />
+
Esc
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/admin-shell.tsx b/src/components/admin-shell.tsx
index 69d9b3e..3e1dcc3 100644
--- a/src/components/admin-shell.tsx
+++ b/src/components/admin-shell.tsx
@@ -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>;
+
+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;
+ groups: ReturnType;
+ badges: AdminBadges;
onNavigate?: () => void;
+ waitingLabel: (count: number) => string;
}) {
return (
<>
- {adminNav.map((item) => {
- const active = isActiveHref(pathname, item.href);
- return (
-
- {/* Wabe statt farbigem Seitenbalken markiert die aktive Seite. */}
-
- {item.label}
-
- );
- })}
+ {groups.map((group, groupIndex) => (
+ 0 ? "mt-5" : ""}>
+ {group.label ? (
+
{group.label}
+ ) : null}
+ {group.items.map((item) => {
+ const active = isActiveHref(pathname, item.href);
+ const count = badges[item.href] ?? 0;
+ return (
+
+ {/* Aktive Seite als weich einblendende Flaeche, die beim
+ Wechsel zur neuen Seite hinueberwandert. */}
+ {active ? (
+
+ ) : null}
+
+
{item.label}
+ {count > 0 ? (
+
+ {count}
+ {waitingLabel(count)}
+
+ ) : null}
+
+ );
+ })}
+
+ ))}
>
);
}
function Wordmark() {
return (
-
+
WABENHAIN ADMIN
-
+
+ );
+}
+
+function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; label: string; shortcut: string }) {
+ return (
+
+
+
+
+
+ {label}
+ {shortcut}
+
);
}
interface AdminShellProps {
adminName: string;
+ badges?: AdminBadges;
logoutAction: () => void | Promise;
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 = (
+
+
{t.adminShell.loggedInAs(adminName)}
+
+
+ );
return (
);
}
diff --git a/src/components/hide-on-admin.tsx b/src/components/hide-on-admin.tsx
new file mode 100644
index 0000000..6c7de21
--- /dev/null
+++ b/src/components/hide-on-admin.tsx
@@ -0,0 +1,16 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { usePathname } from "next/navigation";
+
+/**
+ * Blendet Shop-Kopf- und -Fusszeile im Admin-Bereich aus: dort arbeitet die
+ * Imkerei in einem eigenen Arbeitsbereich mit Seitenleiste
+ * (components/admin-shell.tsx), statt zwischen Shop-Navigation und
+ * Admin-Menue zu stehen.
+ */
+export function HideOnAdmin({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ if (pathname === "/admin" || pathname.startsWith("/admin/")) return null;
+ return <>{children}>;
+}
diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts
index 2f6d982..c7472c1 100644
--- a/src/i18n/dictionaries/de.ts
+++ b/src/i18n/dictionaries/de.ts
@@ -1234,6 +1234,19 @@ export const de: Dictionary = {
noVouchersYet: "Noch keine Gutscheine eingelöst.",
usedCountLabel: (count, maxUses) => `${count}× genutzt${maxUses ? ` / ${maxUses}` : ""}`,
viewAllOrders: "Alle Bestellungen ansehen →",
+ greeting: (part, name) =>
+ `${part === "morning" ? "Guten Morgen" : part === "day" ? "Hallo" : "Guten Abend"}, ${name}.`,
+ todayRevenueLabel: "Umsatz heute",
+ monthRevenueLabel: "Umsatz diesen Monat",
+ vsYesterday: (delta, up) => `${up ? "↑" : "↓"} ${delta} gegenüber gestern`,
+ toDoHeading: "Zu erledigen",
+ toDoOrders: (count) => (count === 1 ? "1 Bestellung wartet auf Versand" : `${count} Bestellungen warten auf Versand`),
+ toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`),
+ toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`),
+ toDoInquiries: (count) => (count === 1 ? "1 neue Firmenanfrage" : `${count} neue Firmenanfragen`),
+ allDoneTitle: "Alles erledigt.",
+ allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.",
+ chartTooltip: (day, value) => `${day}: ${value}`,
},
adminContentPage: {
backToDashboard: "Zurück zum Dashboard",
@@ -1308,6 +1321,22 @@ export const de: Dictionary = {
closeMenu: "Menü schließen",
loggedInAs: (name) => `Angemeldet als ${name}`,
logout: "Abmelden",
+ groupSales: "Verkauf",
+ groupRange: "Sortiment",
+ groupCustomers: "Kundschaft",
+ groupSite: "Seite",
+ paletteOpen: "Schnellsuche",
+ paletteShortcut: "Strg K",
+ palettePlaceholder: "Wohin? Seite oder Aktion suchen …",
+ paletteNoResults: "Nichts gefunden.",
+ paletteSectionPages: "Seiten",
+ paletteSectionActions: "Aktionen",
+ actionNewProduct: "Neues Produkt anlegen",
+ actionNewVoucher: "Neuen Gutschein anlegen",
+ actionNewUser: "Neues Konto anlegen",
+ actionNewShippingTier: "Neue Versandstufe anlegen",
+ actionViewShop: "Shop ansehen",
+ badgeWaiting: (count) => `${count} warten`,
twoFactorWarning:
"Dieses Admin-Konto hat noch keine Zwei-Faktor-Authentifizierung aktiviert, empfohlen für Konten mit Zugriff auf Bestellungen, Rückerstattungen und Kundendaten.",
twoFactorSetupLink: "Jetzt einrichten →",
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts
index b9c6828..a400e53 100644
--- a/src/i18n/dictionaries/en.ts
+++ b/src/i18n/dictionaries/en.ts
@@ -1220,6 +1220,19 @@ export const en: Dictionary = {
noVouchersYet: "No vouchers redeemed yet.",
usedCountLabel: (count, maxUses) => `used ${count}×${maxUses ? ` / ${maxUses}` : ""}`,
viewAllOrders: "View all orders →",
+ greeting: (part, name) =>
+ `${part === "morning" ? "Good morning" : part === "day" ? "Hello" : "Good evening"}, ${name}.`,
+ todayRevenueLabel: "Revenue today",
+ monthRevenueLabel: "Revenue this month",
+ vsYesterday: (delta, up) => `${up ? "↑" : "↓"} ${delta} vs. yesterday`,
+ toDoHeading: "To do",
+ toDoOrders: (count) => (count === 1 ? "1 order waiting to ship" : `${count} orders waiting to ship`),
+ toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`),
+ toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`),
+ toDoInquiries: (count) => (count === 1 ? "1 new business inquiry" : `${count} new business inquiries`),
+ allDoneTitle: "All done.",
+ allDoneText: "Nothing is waiting for you right now. Time for the bees.",
+ chartTooltip: (day, value) => `${day}: ${value}`,
},
adminContentPage: {
backToDashboard: "Back to dashboard",
@@ -1294,6 +1307,22 @@ export const en: Dictionary = {
closeMenu: "Close menu",
loggedInAs: (name) => `Signed in as ${name}`,
logout: "Sign out",
+ groupSales: "Sales",
+ groupRange: "Range",
+ groupCustomers: "Customers",
+ groupSite: "Site",
+ paletteOpen: "Quick search",
+ paletteShortcut: "Ctrl K",
+ palettePlaceholder: "Where to? Search a page or action …",
+ paletteNoResults: "Nothing found.",
+ paletteSectionPages: "Pages",
+ paletteSectionActions: "Actions",
+ actionNewProduct: "Create new product",
+ actionNewVoucher: "Create new voucher",
+ actionNewUser: "Create new account",
+ actionNewShippingTier: "Create new shipping tier",
+ actionViewShop: "View shop",
+ badgeWaiting: (count) => `${count} waiting`,
twoFactorWarning:
"This admin account doesn't have two-factor authentication enabled yet, recommended for accounts with access to orders, refunds, and customer data.",
twoFactorSetupLink: "Set up now →",
diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts
index d9ee27a..52ea003 100644
--- a/src/i18n/dictionary-type.ts
+++ b/src/i18n/dictionary-type.ts
@@ -895,6 +895,18 @@ export interface Dictionary {
noVouchersYet: string;
usedCountLabel: (count: number, maxUses: number | null) => string;
viewAllOrders: string;
+ greeting: (part: "morning" | "day" | "evening", name: string) => string;
+ todayRevenueLabel: string;
+ monthRevenueLabel: string;
+ vsYesterday: (delta: string, up: boolean) => string;
+ toDoHeading: string;
+ toDoOrders: (count: number) => string;
+ toDoReturns: (count: number) => string;
+ toDoReviews: (count: number) => string;
+ toDoInquiries: (count: number) => string;
+ allDoneTitle: string;
+ allDoneText: string;
+ chartTooltip: (day: string, value: string) => string;
};
adminContentPage: {
backToDashboard: string;
@@ -964,6 +976,22 @@ export interface Dictionary {
closeMenu: string;
loggedInAs: (name: string) => string;
logout: string;
+ groupSales: string;
+ groupRange: string;
+ groupCustomers: string;
+ groupSite: string;
+ paletteOpen: string;
+ paletteShortcut: string;
+ palettePlaceholder: string;
+ paletteNoResults: string;
+ paletteSectionPages: string;
+ paletteSectionActions: string;
+ actionNewProduct: string;
+ actionNewVoucher: string;
+ actionNewUser: string;
+ actionNewShippingTier: string;
+ actionViewShop: string;
+ badgeWaiting: (count: number) => string;
twoFactorWarning: string;
twoFactorSetupLink: string;
};