Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
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]>
604 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|