Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem Satz Klassen entstehen: - heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme - surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen - forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe, Admin-Leiste), cream der helle Text darauf - honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird im Dunkelmodus heller und bleibt lesbar Codemod ueber src: text-/border-forest -> *-heading, bg-cream -> bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich. Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme" (nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der Systemeinstellung. Gewaehlter und automatischer Dunkelblock in globals.css werden per Test auf Gleichheit geprueft. Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst ab 1680 px. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
592 lines
26 KiB
TypeScript
592 lines
26 KiB
TypeScript
import Link from "next/link";
|
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
|
import { prisma } from "@/lib/prisma";
|
|
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() }, status: { not: "STORNIERT" } },
|
|
}),
|
|
prisma.order.aggregate({
|
|
_sum: { totalCents: true },
|
|
where: { createdAt: { gte: startOfMonth() }, status: { not: "STORNIERT" } },
|
|
}),
|
|
prisma.order.count({
|
|
where: { status: { notIn: ["VERSENDET", "ABGEHOLT", "STORNIERT"] } },
|
|
}),
|
|
prisma.inventory.aggregate({ _sum: { quantity: true } }),
|
|
prisma.orderItem.aggregate({ _sum: { quantity: true } }),
|
|
prisma.order.aggregate({
|
|
_avg: { totalCents: true },
|
|
_count: true,
|
|
where: { status: { not: "STORNIERT" } },
|
|
}),
|
|
// 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: { status: { not: "STORNIERT" } } },
|
|
_sum: { quantity: true, totalCents: true },
|
|
}),
|
|
prisma.order.findMany({
|
|
where: { createdAt: { gte: daysAgo(REVENUE_TREND_DAYS - 1) }, status: { not: "STORNIERT" } },
|
|
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 }, status: { not: "STORNIERT" } },
|
|
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));
|
|
|
|
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.
|
|
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>
|
|
);
|
|
}
|