Files
Wabenhain/src/app/admin/page.tsx
T
Nick SzaunigandClaude Opus 5.5 93b589ff04
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
feat(stock): keep shop stock in sync with ApiCore, write sales back
ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:16:54 +02:00

604 lines
26 KiB
TypeScript

import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma";
import { pendingReviewWhere } from "@/lib/review-status";
import { countApiCoreStockIssues } from "@/lib/apicore-stock/dashboard";
import { COUNTED_SALE_WHERE } from "@/lib/sales";
import { formatPrice } from "@/lib/format";
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,
height: 20,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
function CoinIcon() {
return (
<svg {...DASHBOARD_ICON_PROPS}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v10M9 9.5a2 2 0 0 1 2-1.5h1.5a2 2 0 0 1 0 4H11a2 2 0 0 0 0 4h1.5a2 2 0 0 0 2-1.5" />
</svg>
);
}
function OrdersIcon() {
return (
<svg {...DASHBOARD_ICON_PROPS}>
<path d="M4 8l8-4 8 4-8 4-8-4z" />
<path d="M4 8v8l8 4 8-4V8" />
</svg>
);
}
function JarIcon() {
return (
<svg {...DASHBOARD_ICON_PROPS}>
<path d="M8 3h8M9 3v3.5L6.5 9A3 3 0 0 0 5.5 11.3V19a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-7.7A3 3 0 0 0 17.5 9L15 6.5V3" />
</svg>
);
}
function PeopleIcon() {
return (
<svg {...DASHBOARD_ICON_PROPS}>
<circle cx="9" cy="8" r="3" />
<path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" />
<path d="M16 4.5a3 3 0 0 1 0 6" />
<path d="M18.5 14.3c2 .6 3.5 2.7 3.5 5.7" />
</svg>
);
}
/**
* Die Kennzahlen-Kacheln des Dashboards. Bewusst ein Typ und keine
* Wert-Konstante: die Liste wurde nie zur Laufzeit durchlaufen, sondern
* ausschliesslich benutzt, um die Record-/Array-Typen unten vollstaendig zu
* halten — als `as const`-Array landete sie trotzdem im Bundle.
*/
type StatKey =
| "revenueToday"
| "revenueMonth"
| "openOrders"
| "inventory"
| "soldGlasses"
| "avgCart"
| "repeatCustomers";
const statIcons: Record<StatKey, () => ReturnType<typeof CoinIcon>> = {
revenueToday: CoinIcon,
revenueMonth: CoinIcon,
openOrders: OrdersIcon,
inventory: JarIcon,
soldGlasses: JarIcon,
avgCart: CoinIcon,
repeatCustomers: PeopleIcon,
};
function startOfDay(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function startOfMonth(): Date {
const d = new Date();
return new Date(d.getFullYear(), d.getMonth(), 1);
}
const REVENUE_TREND_DAYS = 14;
function daysAgo(n: number): Date {
const d = startOfDay();
d.setDate(d.getDate() - n);
return d;
}
export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const searchParams = await props.searchParams;
const locale = await getLocale();
const t = getDictionary(locale);
const [
revenueToday,
revenueMonth,
openOrders,
inventorySum,
soldGlasses,
orderStats,
soldItems,
recentOrders,
repeatCustomerGroups,
topDiscountCodes,
] = await Promise.all([
prisma.order.aggregate({
_sum: { totalCents: true },
where: { createdAt: { gte: startOfDay() }, ...COUNTED_SALE_WHERE },
}),
prisma.order.aggregate({
_sum: { totalCents: true },
where: { createdAt: { gte: startOfMonth() }, ...COUNTED_SALE_WHERE },
}),
prisma.order.count({
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
}),
prisma.inventory.aggregate({ _sum: { quantity: true } }),
prisma.orderItem.aggregate({ _sum: { quantity: true }, where: { order: COUNTED_SALE_WHERE } }),
prisma.order.aggregate({
_avg: { totalCents: true },
_count: true,
where: COUNTED_SALE_WHERE,
}),
// Summiert wird in der DATENBANK, nicht hier. Frueher lud diese Abfrage
// JEDE Bestellposition, die es je gab, samt Produkt-Join in den Speicher,
// nur um daraus fuenf Zeilen "Top-Produkte" zu rechnen. "order_items" ist
// die am schnellsten wachsende Tabelle des Shops und wird nie
// aufgeraeumt: die Abfrage wurde mit jedem Verkauf teurer — auf der Seite,
// die im Admin-Bereich als erste geoeffnet wird.
//
// Gruppiert wird nach productId UND productName, weil productName ein
// Schnappschuss vom Bestellzeitpunkt ist: ein spaeter umbenanntes Produkt
// ergibt zwei Gruppen. Sie werden unten wieder nach productId
// zusammengefuehrt — genau wie bisher. Die Kategorie steht nicht an der
// Position und kommt deshalb aus einer zweiten, kurzen Abfrage (siehe
// unten); ueber eine Relation laesst sich nicht gruppieren.
prisma.orderItem.groupBy({
by: ["productId", "productName"],
where: { order: COUNTED_SALE_WHERE },
_sum: { quantity: true, totalCents: true },
}),
prisma.order.findMany({
where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, ...COUNTED_SALE_WHERE },
select: { createdAt: true, totalCents: true },
}),
// Stammkunden: Kund:innen mit mehr als einer nicht-stornierten Bestellung.
// Zaehlen und filtern erledigt die Datenbank per HAVING; zurueck kommt
// eine Zeile je Stammkundin/Stammkunde. Frueher wurde hier JEDE Bestellung
// geladen, die es je gab, nur um im Speicher eine einzige Zahl daraus zu
// machen — mit der Begruendung, Prisma koenne kein HAVING. Es kann.
prisma.order.groupBy({
by: ["customerId"],
where: { customerId: { not: null }, ...COUNTED_SALE_WHERE },
having: { customerId: { _count: { gt: 1 } } },
}),
prisma.discountCode.findMany({
where: { usedCount: { gt: 0 } },
orderBy: { usedCount: "desc" },
take: 5,
}),
]);
// Eine Zeile je Stammkundschaft — das HAVING oben hat bereits gefiltert.
const repeatCustomerCount = repeatCustomerGroups.length;
// Die Kategorie haengt am Produkt, nicht an der Bestellposition, und laesst
// sich deshalb nicht mitgruppieren. Diese Nachfrage betrifft nur die
// tatsaechlich verkauften Produkte — eine Handvoll Zeilen ueber den
// Primaerschluessel, nicht die Bestellhistorie. Ein Produkt mit
// Bestellpositionen kann nicht geloescht werden (onDelete: Restrict), es
// gibt hier also zu jeder ID auch eine Zeile.
const soldProductIds = [...new Set(soldItems.map((group) => group.productId))];
const soldProducts =
soldProductIds.length > 0
? await prisma.product.findMany({
where: { id: { in: soldProductIds } },
select: { id: true, category: true },
})
: [];
const categoryByProductId = new Map(soldProducts.map((product) => [product.id, product.category]));
const statLabels: Record<StatKey, string> = {
revenueToday: t.adminDashboardPage.statRevenueToday,
revenueMonth: t.adminDashboardPage.statRevenueMonth,
openOrders: t.adminDashboardPage.statOpenOrders,
inventory: t.adminDashboardPage.statInventory,
soldGlasses: t.adminDashboardPage.statSoldGlasses,
avgCart: t.adminDashboardPage.statAvgCart,
repeatCustomers: t.adminDashboardPage.statRepeatCustomers,
};
const stats: { key: StatKey; value: string }[] = [
{ key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) },
{ key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) },
{ key: "openOrders", value: String(openOrders) },
{ key: "inventory", value: String(inventorySum._sum.quantity ?? 0) },
{ key: "soldGlasses", value: String(soldGlasses._sum.quantity ?? 0) },
{
key: "avgCart",
value: formatPrice(Math.round(orderStats._avg.totalCents ?? 0)),
},
{ key: "repeatCustomers", value: String(repeatCustomerCount) },
];
const productTotals = new Map<string, { name: string; quantity: number; revenueCents: number }>();
const categoryTotals = new Map<string, { quantity: number; revenueCents: number }>();
for (const group of soldItems) {
// Ein umbenanntes Produkt kommt als zwei Gruppen an (productName ist ein
// Schnappschuss) — hier wieder zu einer Zeile zusammengefuehrt. Der zuerst
// gesehene Name gewinnt, wie zuvor.
const quantity = group._sum.quantity ?? 0;
const revenueCents = group._sum.totalCents ?? 0;
const product = productTotals.get(group.productId) ?? {
name: group.productName,
quantity: 0,
revenueCents: 0,
};
product.quantity += quantity;
product.revenueCents += revenueCents;
productTotals.set(group.productId, product);
const category = categoryByProductId.get(group.productId) ?? "HONIG";
const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 };
categoryTotal.quantity += quantity;
categoryTotal.revenueCents += revenueCents;
categoryTotals.set(category, categoryTotal);
}
const topProducts = [...productTotals.values()].sort((a, b) => b.revenueCents - a.revenueCents).slice(0, 5);
const revenueByCategory = [...categoryTotals.entries()].sort((a, b) => b[1].revenueCents - a[1].revenueCents);
const maxCategoryRevenue = Math.max(1, ...revenueByCategory.map(([, v]) => v.revenueCents));
const revenueByDay = new Map<string, number>();
for (let i = REVENUE_TREND_DAYS - 1; i >= 0; i--) {
revenueByDay.set(daysAgo(i).toISOString().slice(0, 10), 0);
}
for (const order of recentOrders) {
const key = order.createdAt.toISOString().slice(0, 10);
revenueByDay.set(key, (revenueByDay.get(key) ?? 0) + order.totalCents);
}
const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
// Bestandsabgleich mit ApiCore (nur wenn eingerichtet).
const stockIssues = await countApiCoreStockIssues();
const stockOverdue = stockIssues.overdue;
const stockRejected = stockIssues.rejected;
const unlinkedProducts = stockIssues.unlinked;
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: pendingReviewWhere() }),
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) },
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
].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.
const [uploadHealth, uploadVolume] = await Promise.all([
checkUploadFiles().catch(() => null),
checkUploadVolume().catch(() => null),
]);
return (
<div>
{/* 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-heading">
{t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}
</h1>
</div>
{/* Ergebnis des ApiCore-Laufs (nur direkt nach dem Klick sichtbar). */}
<ApiCoreSyncResult searchParams={searchParams} />
{/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
ein "emptyDir" statt eines PersistentVolumeClaim). Ohne diesen
Hinweis faellt das erst auf, wenn Kundschaft im Shop leere
Produktkacheln sieht — im Log steht nichts. */}
{/* Schlaegt an, BEVOR etwas verloren geht: liegt das
Upload-Verzeichnis im Container statt auf einem gemounteten
Volume, sind die Dateien beim naechsten Redeploy weg — auch wenn
gerade noch alle vorhanden sind. */}
{uploadVolume && !uploadVolume.mounted ? (
<div className="mb-8 border border-honey/50 bg-honey/10 p-4">
<p className="text-sm text-honey-dark">
{t.adminDashboardPage.uploadsNotPersistentTitle}
</p>
<p className="mt-2 text-xs text-ink/70">
{t.adminDashboardPage.uploadsNotPersistentHint(uploadVolume.uploadDir)}
</p>
</div>
) : null}
{uploadHealth && uploadHealth.missing > 0 ? (
<div className="mb-8 border border-red-700/40 bg-red-50 p-4">
<p className="text-sm text-red-800">
{t.adminDashboardPage.missingUploadsTitle(uploadHealth.missing, uploadHealth.checked)}
</p>
<p className="mt-2 text-xs text-red-800/80">
{t.adminDashboardPage.missingUploadsHint}
</p>
{uploadHealth.examples.length > 0 ? (
<ul className="mt-2 text-xs text-red-800/70">
{uploadHealth.examples.map((example) => (
<li key={example}>{example}</li>
))}
</ul>
) : null}
</div>
) : null}
<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-heading">{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-heading"
>
<span className="flex h-7 min-w-7 items-center justify-center rounded-full bg-honey-deep 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-heading"
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="font-serif text-xl text-heading">{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-heading 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-heading">{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 transition-colors hover:border-ink/20">
<h2 className="text-lg text-heading 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="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-heading/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-heading whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
</li>
))}
</ul>
)}
</div>
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-lg text-heading mb-4">
{t.adminDashboardPage.revenueByCategoryHeading}
</h2>
{revenueByCategory.length === 0 ? (
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
) : (
<ul className="space-y-3 text-sm">
{revenueByCategory.map(([category, totals]) => (
<li key={category}>
<div className="flex items-center justify-between mb-1">
<span className="text-ink/70">
{t.common.categories[category as keyof typeof t.common.categories] ?? category}
</span>
<span className="text-heading">{formatPrice(totals.revenueCents)}</span>
</div>
{/* 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="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>
</li>
))}
</ul>
)}
</div>
</div>
<div className="mt-6 grid lg:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-lg text-heading mb-4">
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
</h2>
{/* 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>
<span>{t.adminDashboardPage.todayLabel}</span>
</div>
</div>
<div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-lg text-heading mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
{topDiscountCodes.length === 0 ? (
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
) : (
<ul className="space-y-3 text-sm">
{topDiscountCodes.map((code) => (
<li key={code.id} className="flex items-center justify-between gap-4">
<span className="text-ink/70">
{code.code}{" "}
<span className="text-ink/65">
({code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)})
</span>
</span>
<span className="text-heading whitespace-nowrap">
{t.adminDashboardPage.usedCountLabel(code.usedCount, code.maxUses)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
<p className="mt-6 text-xs text-ink/65">
<Link href="/admin/bestellungen" className="underline">
{t.adminDashboardPage.viewAllOrders}
</Link>
</p>
{/* Der eine Sync-Knopf fuer Ernten, Chargen und Produkte. */}
<div className="mt-10 max-w-3xl">
<ApiCoreSyncPanel returnTo="/admin" />
</div>
</div>
);
}