From c95c713717f8f8a71f3f660f0395a2e9f4dfb55b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 25 Sep 2026 13:45:24 +0200 Subject: [PATCH] feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche - Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes
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) --- src/app/admin/bestellungen/page.tsx | 2 +- src/app/admin/layout.tsx | 20 ++ src/app/admin/page.tsx | 243 +++++++++++++++---- src/app/globals.css | 53 ++++ src/app/layout.tsx | 9 +- src/components/admin-command-palette.tsx | 172 +++++++++++++ src/components/admin-shell.tsx | 294 ++++++++++++++++------- src/components/hide-on-admin.tsx | 16 ++ src/i18n/dictionaries/de.ts | 29 +++ src/i18n/dictionaries/en.ts | 29 +++ src/i18n/dictionary-type.ts | 28 +++ 11 files changed, 762 insertions(+), 133 deletions(-) create mode 100644 src/components/admin-command-palette.tsx create mode 100644 src/components/hide-on-admin.tsx diff --git a/src/app/admin/bestellungen/page.tsx b/src/app/admin/bestellungen/page.tsx index 47758e0..002ab4b 100644 --- a/src/app/admin/bestellungen/page.tsx +++ b/src/app/admin/bestellungen/page.tsx @@ -124,7 +124,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun {orders.map((order) => ( - + {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. */} + +

{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/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; };