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
@@ -124,7 +124,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4 text-forest">
|
||||
<td className="p-4 text-forest whitespace-nowrap">
|
||||
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
|
||||
{order.orderNumber}
|
||||
</Link>
|
||||
|
||||
@@ -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 (
|
||||
<AdminShell
|
||||
adminName={`${admin.firstName} ${admin.lastName}`}
|
||||
badges={{
|
||||
"/admin/bestellungen": openOrders,
|
||||
"/admin/retouren": openReturns,
|
||||
"/admin/bewertungen": pendingReviews,
|
||||
"/admin/anfragen": newInquiries,
|
||||
}}
|
||||
logoutAction={logoutAction}
|
||||
>
|
||||
{children}
|
||||
|
||||
+194
-49
@@ -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 (
|
||||
<div>
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
|
||||
{/* Begruessung statt nackter Ueberschrift: der Tag beginnt hier. */}
|
||||
<div className="mb-10">
|
||||
<p className="text-sm text-ink/60">{todayLine}</p>
|
||||
<h1 className="mt-1 text-4xl md:text-5xl leading-tight text-forest">
|
||||
{t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{stats.map((stat) => {
|
||||
const Icon = statIcons[stat.key];
|
||||
const primary = stat.key === "revenueToday";
|
||||
return (
|
||||
<div
|
||||
key={stat.key}
|
||||
className={`border p-6 flex items-start gap-4 ${
|
||||
primary ? "border-honey/40 bg-honey/5" : "bg-background border-ink/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 border ${
|
||||
primary ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
</span>
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
{/* Die eine Leitzahl der Ansicht: Umsatz heute (Grundschrift,
|
||||
proportionale Ziffern, siehe dataviz-Skill). */}
|
||||
<section className="relative overflow-hidden bg-forest p-8 text-cream lg:col-span-3">
|
||||
{/* Mini-Verlauf der letzten Tage: zeigt ohne Achsen, ob es
|
||||
aufwaerts geht — die Details stehen im Diagramm weiter unten. */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox={`0 0 ${Math.max(1, revenueTrend.length - 1) * 10} 40`}
|
||||
preserveAspectRatio="none"
|
||||
className="absolute right-8 top-10 hidden h-20 w-2/5 sm:block"
|
||||
>
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="var(--color-honey-light)"
|
||||
strokeOpacity="0.6"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
points={revenueTrend
|
||||
.map(([, cents], i) => `${i * 10},${38 - (cents / maxDailyRevenue) * 34}`)
|
||||
.join(" ")}
|
||||
/>
|
||||
<circle
|
||||
cx={(revenueTrend.length - 1) * 10}
|
||||
cy={38 - ((revenueTrend[revenueTrend.length - 1]?.[1] ?? 0) / maxDailyRevenue) * 34}
|
||||
r="3"
|
||||
fill="var(--color-honey-light)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-cream/70">{t.adminDashboardPage.todayRevenueLabel}</p>
|
||||
<p className="mt-2 text-6xl font-semibold tracking-tight">{formatPrice(revenueToday._sum.totalCents ?? 0)}</p>
|
||||
{todayCents !== 0 || yesterdayCents !== 0 ? (
|
||||
<p className={`mt-3 text-sm ${deltaCents >= 0 ? "text-honey-light" : "text-cream/70"}`}>
|
||||
{t.adminDashboardPage.vsYesterday(formatPrice(Math.abs(deltaCents)), deltaCents >= 0)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-8 border-t border-cream/15 pt-4 text-sm text-cream/70">
|
||||
{t.adminDashboardPage.monthRevenueLabel}{" "}
|
||||
<span className="ml-2 text-lg font-semibold text-cream">{formatPrice(revenueMonth._sum.totalCents ?? 0)}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Was jetzt ansteht — direkt anklickbar. Wartet nichts, gibt es eine
|
||||
kleine Belohnung statt einer leeren Liste. */}
|
||||
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
|
||||
<h2 className="text-lg text-forest">{t.adminDashboardPage.toDoHeading}</h2>
|
||||
{toDo.length > 0 ? (
|
||||
<ul className="mt-4 divide-y divide-ink/10">
|
||||
{toDo.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="group flex items-center gap-3 py-3 text-sm text-ink/80 transition-colors hover:text-forest"
|
||||
>
|
||||
<span className="flex h-7 min-w-7 items-center justify-center rounded-full bg-honey-dark px-2 text-xs font-semibold text-cream tabular-nums">
|
||||
{item.count}
|
||||
</span>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 text-ink/40 transition-[color,transform] duration-300 group-hover:translate-x-1 group-hover:text-forest"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
>
|
||||
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="mt-6 flex items-start gap-4">
|
||||
<svg viewBox="0 0 24 26" aria-hidden="true" className="motion-stamp h-10 w-10 flex-shrink-0 animate-[wh-stamp_0.7s_var(--ease-arrive)_both]">
|
||||
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="var(--color-honey)" />
|
||||
<path d="M7.5 13.2l3 3 6-6.4" stroke="var(--color-cream)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-xs text-ink/65 tracking-wide-caps">{statLabels[stat.key]}</p>
|
||||
<p className="mt-1 text-2xl text-forest">{stat.value}</p>
|
||||
<p className="font-serif text-xl text-forest">{t.adminDashboardPage.allDoneTitle}</p>
|
||||
<p className="mt-1 text-sm text-ink/65">{t.adminDashboardPage.allDoneText}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{stats
|
||||
.filter((stat) => stat.key !== "revenueToday" && stat.key !== "revenueMonth")
|
||||
.map((stat) => {
|
||||
const Icon = statIcons[stat.key];
|
||||
return (
|
||||
<div
|
||||
key={stat.key}
|
||||
className="group border border-ink/10 bg-background p-5 transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-honey/50"
|
||||
>
|
||||
<span className="flex h-9 w-9 items-center justify-center border border-ink/10 text-forest transition-colors group-hover:border-honey/50 group-hover:text-honey-dark">
|
||||
<Icon />
|
||||
</span>
|
||||
<p className="mt-4 text-xs text-ink/60">{statLabels[stat.key]}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-forest">{stat.value}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
|
||||
{topProducts.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
|
||||
) : (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{topProducts.map((product, i) => (
|
||||
<li key={product.name} className="flex items-center justify-between gap-4">
|
||||
<span className="text-ink/70">
|
||||
{i + 1}. {product.name}{" "}
|
||||
<span className="flex items-center gap-3 text-ink/80">
|
||||
<span className="relative flex h-6 w-6 flex-shrink-0 items-center justify-center text-[10px] font-semibold text-cream">
|
||||
<HexMark className={`absolute inset-0 h-6 w-6 ${i === 0 ? "text-[var(--color-chart-honey)]" : "text-forest/70"}`} />
|
||||
<span className="relative">{i + 1}</span>
|
||||
</span>
|
||||
{product.name}{" "}
|
||||
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
|
||||
</span>
|
||||
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
|
||||
@@ -348,8 +477,8 @@ export default async function AdminDashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">
|
||||
{t.adminDashboardPage.revenueByCategoryHeading}
|
||||
</h2>
|
||||
{revenueByCategory.length === 0 ? (
|
||||
@@ -364,9 +493,10 @@ export default async function AdminDashboardPage() {
|
||||
</span>
|
||||
<span className="text-forest">{formatPrice(totals.revenueCents)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-ink/10">
|
||||
{/* Meter: Spur ist eine hellere Stufe desselben Tons. */}
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--color-chart-honey)_15%,transparent)]">
|
||||
<div
|
||||
className="h-full bg-honey"
|
||||
className="motion-grow h-full origin-left rounded-full bg-[var(--color-chart-honey)] animate-[wh-grow_0.8s_var(--ease-arrive)_both]"
|
||||
style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -378,23 +508,38 @@ export default async function AdminDashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid lg:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">
|
||||
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
|
||||
</h2>
|
||||
<div className="flex items-end gap-1 h-24">
|
||||
{revenueTrend.map(([day, revenueCents]) => (
|
||||
<div
|
||||
key={day}
|
||||
className="flex-1 h-full flex flex-col justify-end"
|
||||
title={`${day}: ${formatPrice(revenueCents)}`}
|
||||
>
|
||||
<div
|
||||
className="bg-honey w-full"
|
||||
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{/* 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. */}
|
||||
<div className="relative flex h-32 items-end gap-[2px] border-b border-ink/15">
|
||||
{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 (
|
||||
<div key={day} className="group relative flex h-full flex-1 flex-col justify-end" tabIndex={0}>
|
||||
<div
|
||||
className={`w-full rounded-t-[4px] bg-[var(--color-chart-honey)] transition-[opacity,filter] duration-200 group-hover:brightness-110 ${
|
||||
isToday ? "" : "opacity-70 group-hover:opacity-100"
|
||||
}`}
|
||||
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
|
||||
/>
|
||||
<span
|
||||
role="tooltip"
|
||||
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 whitespace-nowrap bg-forest px-2 py-1 text-[11px] text-cream opacity-0 shadow transition-opacity duration-150 group-hover:opacity-100 group-focus:opacity-100"
|
||||
>
|
||||
{t.adminDashboardPage.chartTooltip(label, formatPrice(revenueCents))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-[10px] text-ink/65">
|
||||
<span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span>
|
||||
@@ -402,8 +547,8 @@ export default async function AdminDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
|
||||
{topDiscountCodes.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user