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:
1 parent
5c76fcc266
commit
c7927f2883
240 files changed
+26096
-137
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user