feat: Wabenhain Honig-Shop Anwendung

Vollstaendige Next.js/Prisma-E-Commerce-Anwendung fuer eine Imkerei:
Shop, Warenkorb, Stripe-Checkout, Kundenkonto mit 2FA, Admin-Bereich
(Produkte, Ernten, Chargen, Bestellungen, Gutscheine, admin-editierbare
Website-Inhalte), i18n (DE/EN), Rate-Limiting/CSP/Audit-Log-Haertung,
PostgreSQL- und MySQL-Unterstuetzung ueber austauschbare Prisma-
Treiber-Adapter, Unit-/Komponenten-/E2E-Tests.
This commit is contained in:
Nick Szaunig committed 2026-08-17 14:30:28 +02:00
1 parent 5c76fcc266
commit c7927f2883
240 files changed
+26096 -137

No files matched your search

+357
View File
@@ -0,0 +1,357 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
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>
);
}
const STAT_KEYS = [
"revenueToday",
"revenueMonth",
"openOrders",
"inventory",
"soldGlasses",
"avgCart",
"repeatCustomers",
] as const;
const statIcons: Record<(typeof STAT_KEYS)[number], () => 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() {
const t = getDictionary(await getLocale());
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" } },
}),
// Fuer Top-Produkte/Umsatz nach Kategorie: kein Prisma-groupBy ueber
// Relationen moeglich, deshalb Rohdaten laden und im Code aggregieren
// (bei der Groessenordnung dieses Shops unproblematisch).
prisma.orderItem.findMany({
where: { order: { status: { not: "STORNIERT" } } },
select: {
productId: true,
productName: true,
quantity: true,
totalCents: true,
product: { select: { category: 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.
// Kein Prisma-groupBy mit HAVING count > 1 in einer Abfrage moeglich,
// deshalb Rohdaten laden und im Code gruppieren.
prisma.order.findMany({
where: { customerId: { not: null }, status: { not: "STORNIERT" } },
select: { customerId: true },
}),
prisma.discountCode.findMany({
where: { usedCount: { gt: 0 } },
orderBy: { usedCount: "desc" },
take: 5,
}),
]);
const ordersPerCustomer = new Map<string, number>();
for (const order of repeatCustomerGroups) {
if (!order.customerId) continue;
ordersPerCustomer.set(order.customerId, (ordersPerCustomer.get(order.customerId) ?? 0) + 1);
}
const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length;
const statLabels: Record<(typeof STAT_KEYS)[number], 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: (typeof STAT_KEYS)[number]; 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 item of soldItems) {
const product = productTotals.get(item.productId) ?? {
name: item.productName,
quantity: 0,
revenueCents: 0,
};
product.quantity += item.quantity;
product.revenueCents += item.totalCents;
productTotals.set(item.productId, product);
const category = item.product?.category ?? "HONIG";
const categoryTotal = categoryTotals.get(category) ?? { quantity: 0, revenueCents: 0 };
categoryTotal.quantity += item.quantity;
categoryTotal.revenueCents += item.totalCents;
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));
return (
<div>
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1>
<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>
<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>
</div>
</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>
{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="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
</span>
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
</li>
))}
</ul>
)}
</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.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-forest">{formatPrice(totals.revenueCents)}</span>
</div>
<div className="h-1.5 bg-ink/10">
<div
className="h-full bg-honey"
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">
<h2 className="text-xs tracking-wide-caps text-ink/65 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>
))}
</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">
<h2 className="text-xs tracking-wide-caps text-ink/65 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-forest 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>
</div>
);
}