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 ( ); } function OrdersIcon() { return ( ); } function JarIcon() { return ( ); } function PeopleIcon() { return ( ); } /** * 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 ReturnType> = { 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 = { 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(); const categoryTotals = new Map(); 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(); 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 (
{/* Begruessung statt nackter Ueberschrift: der Tag beginnt hier. */}

{todayLine}

{t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}

{/* Ergebnis des ApiCore-Laufs (nur direkt nach dem Klick sichtbar). */} {/* 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 ? (

{t.adminDashboardPage.uploadsNotPersistentTitle}

{t.adminDashboardPage.uploadsNotPersistentHint(uploadVolume.uploadDir)}

) : null} {uploadHealth && uploadHealth.missing > 0 ? (

{t.adminDashboardPage.missingUploadsTitle(uploadHealth.missing, uploadHealth.checked)}

{t.adminDashboardPage.missingUploadsHint}

{uploadHealth.examples.length > 0 ? (
    {uploadHealth.examples.map((example) => (
  • {example}
  • ))}
) : null}
) : null}
{/* 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}
  • ))}
) : (

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

{topProducts.length === 0 ? (

{t.adminDashboardPage.noSalesYet}

) : (
    {topProducts.map((product, i) => (
  • {i + 1} {product.name}{" "} {t.adminDashboardPage.unitsSuffix(product.quantity)} {formatPrice(product.revenueCents)}
  • ))}
)}

{t.adminDashboardPage.revenueByCategoryHeading}

{revenueByCategory.length === 0 ? (

{t.adminDashboardPage.noSalesYet}

) : (
    {revenueByCategory.map(([category, totals]) => (
  • {t.common.categories[category as keyof typeof t.common.categories] ?? category} {formatPrice(totals.revenueCents)}
    {/* Meter: Spur ist eine hellere Stufe desselben Tons. */}
  • ))}
)}

{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}

{/* 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)} {t.adminDashboardPage.todayLabel}

{t.adminDashboardPage.topVouchersHeading}

{topDiscountCodes.length === 0 ? (

{t.adminDashboardPage.noVouchersYet}

) : (
    {topDiscountCodes.map((code) => (
  • {code.code}{" "} ({code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}) {t.adminDashboardPage.usedCountLabel(code.usedCount, code.maxUses)}
  • ))}
)}

{t.adminDashboardPage.viewAllOrders}

{/* Der eine Sync-Knopf fuer Ernten, Chargen und Produkte. */}
); }