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

+14
View File
@@ -0,0 +1,14 @@
// Barrel-Datei: die eigentlichen Server Actions leben domainweise unter
// ./actions/* (waren zuvor alle in dieser einen 800+-Zeilen-Datei). Bestehende
// Importe ("@/app/admin/actions" bzw. "../actions" aus den Admin-Seiten)
// bleiben dadurch unveraendert funktionsfaehig.
export { requireAdmin } from "./actions/_shared";
export * from "./actions/orders";
export * from "./actions/harvests-batches";
export * from "./actions/products";
export * from "./actions/inquiries";
export * from "./actions/customers";
export * from "./actions/shipping";
export * from "./actions/reviews";
export * from "./actions/discount-codes";
export * from "./actions/content";
+55
View File
@@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentAdmin } from "@/lib/auth";
import type { CustomerRole } from "@/generated/prisma/client";
/**
* Autoritative Rollenpruefung fuer jede Admin-Mutation. Der Proxy
* (src/proxy.ts) blockt fremde Requests bereits auf Netzwerkebene, aber
* Server Actions sind eigene POST-Endpunkte und muessen laut Next.js-
* Sicherheitsleitfaden auch fuer sich genommen autorisieren — Render-Zeit-
* Gating (Button wird nur im Admin-UI angezeigt) ist keine Sicherheitsgrenze.
* Gibt das Admin-Konto zurueck, damit Aufrufer es ohne Zweitabfrage fuers
* Audit-Log verwenden koennen.
*/
export async function requireAdmin(): Promise<{ id: string; email: string; role: CustomerRole }> {
const admin = await getCurrentAdmin();
if (!admin) {
throw new Error("Nicht autorisiert.");
}
// Admin-Konten mit Zugriff auf Bestellungen, Rueckerstattungen und
// Kundendaten muessen Zwei-Faktor-Authentifizierung aktiviert haben — das
// Admin-Layout leitet dafuer bereits weiter, diese Pruefung faengt zusaetzlich
// eine direkt aufgerufene Server Action ab, die das Layout umgeht.
if (!admin.twoFactorEnabled) {
throw new Error("Zwei-Faktor-Authentifizierung ist für Admin-Konten erforderlich.");
}
return admin;
}
export function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
export async function isLastAdmin(customerId: string): Promise<boolean> {
const customer = await prisma.customer.findUnique({ where: { id: customerId } });
if (!customer || customer.role !== "ADMIN") return false;
const adminCount = await prisma.customer.count({ where: { role: "ADMIN" } });
return adminCount <= 1;
}
export function str(formData: FormData, key: string): string {
return String(formData.get(key) ?? "").trim();
}
export function num(formData: FormData, key: string): number {
const value = Number(formData.get(key) ?? 0);
// NaN/Infinity (z.B. leeres oder manipuliertes Feld) nicht an Prisma durchreichen
return Number.isFinite(value) ? value : 0;
}
export function strOrNull(formData: FormData, key: string): string | null {
const value = str(formData, key);
return value === "" ? null : value;
}
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { CONTENT_REGISTRY } from "@/lib/content-registry";
import { requireAdmin } from "./_shared";
/**
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
* "en::{key}" (siehe admin/inhalte/page.tsx).
*/
export async function updateSiteContentAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const group = String(formData.get("group") ?? "");
const updates: { key: string; valueDe: string; valueEn: string | null }[] = [];
for (const def of CONTENT_REGISTRY) {
if (def.localized) {
const de = formData.get(`de::${def.key}`);
const en = formData.get(`en::${def.key}`);
if (de === null && en === null) continue;
updates.push({
key: def.key,
valueDe: String(de ?? ""),
valueEn: String(en ?? ""),
});
} else {
const value = formData.get(def.key);
if (value === null) continue;
updates.push({ key: def.key, valueDe: String(value), valueEn: null });
}
}
await prisma.$transaction(
updates.map((u) =>
prisma.siteContent.upsert({
where: { key: u.key },
create: { key: u.key, valueDe: u.valueDe, valueEn: u.valueEn },
update: { valueDe: u.valueDe, valueEn: u.valueEn },
})
)
);
await logAdminAction(admin, {
action: "site_content.update",
entityType: "SiteContent",
entityId: group || undefined,
detail: `${updates.length} Feld(er)`,
});
revalidatePath("/", "layout");
redirect("/admin/inhalte?saved=1");
}
+194
View File
@@ -0,0 +1,194 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
import { createAndSendPasswordResetEmail } from "@/lib/password-reset";
import { createAndSendVerificationEmail } from "@/lib/email-verification";
import { logAdminAction } from "@/lib/audit-log";
import { adminCreateCustomerSchema, adminUpdateCustomerSchema } from "@/lib/validation";
import { requireAdmin, fail, isLastAdmin, str } from "./_shared";
export async function createCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const parsed = adminCreateCustomerSchema.safeParse({
firstName: str(formData, "firstName"),
lastName: str(formData, "lastName"),
email: str(formData, "email"),
role: str(formData, "role") || "KUNDE",
});
if (!parsed.success) {
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const existing = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (existing) {
fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
}
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
// niemandem bekannten Hash. Der Nutzer vergibt sein eigenes Passwort ueber
// den Reset-Link, der sofort per E-Mail verschickt wird.
const unusablePassword = randomBytes(32).toString("hex");
const customer = await prisma.customer.create({
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
email: parsed.data.email,
role: parsed.data.role,
passwordHash: await hashPassword(unusablePassword),
},
});
await createAndSendPasswordResetEmail(customer);
await logAdminAction(admin, {
action: "customer.create",
entityType: "Customer",
entityId: customer.id,
detail: `${customer.email} (${parsed.data.role})`,
});
revalidatePath("/admin/benutzer");
redirect("/admin/benutzer");
}
export async function updateCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const parsed = adminUpdateCustomerSchema.safeParse({
firstName: str(formData, "firstName"),
lastName: str(formData, "lastName"),
email: str(formData, "email"),
phone: str(formData, "phone"),
role: str(formData, "role") || "KUNDE",
});
if (!parsed.success) {
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
if (id === admin.id && parsed.data.role !== admin.role) {
fail(`/admin/benutzer/${id}`, "Du kannst deine eigene Rolle nicht ändern.");
}
const target = await prisma.customer.findUnique({ where: { id } });
if (!target) fail("/admin/benutzer", "Konto nicht gefunden.");
if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && (await isLastAdmin(id))) {
fail(`/admin/benutzer/${id}`, "Der letzte Admin-Account kann nicht herabgestuft werden.");
}
const emailOwner = await prisma.customer.findUnique({ where: { email: parsed.data.email } });
if (emailOwner && emailOwner.id !== id) {
fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
}
const emailChanged = parsed.data.email !== target.email;
const updated = await prisma.customer.update({
where: { id },
data: {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
email: parsed.data.email,
phone: parsed.data.phone || null,
role: parsed.data.role,
// Eine geaenderte Adresse ist nicht automatisch bestaetigt — sonst
// koennte eine falsch eingetragene (oder von einem Admin-Account aus
// manipulierte) Adresse als "verifiziert" gelten, ohne dass deren
// tatsaechliche Inhaberin/Inhaber je einen Bestaetigungslink geoeffnet hat.
...(emailChanged ? { emailVerifiedAt: null } : {}),
},
});
if (emailChanged) {
await createAndSendVerificationEmail(updated);
}
await logAdminAction(admin, {
action: "customer.update",
entityType: "Customer",
entityId: id,
detail: target.role !== parsed.data.role ? `Rolle: ${target.role} → ${parsed.data.role}` : parsed.data.email,
});
revalidatePath("/admin/benutzer");
redirect("/admin/benutzer");
}
export async function blockCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
if (id === admin.id) {
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
}
if (await isLastAdmin(id)) {
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
}
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
// gesperrten Kundschaft sofort ungueltig wird statt erst beim naechsten
// Login (getCurrentCustomer prueft ohnehin zusaetzlich blockedAt direkt).
const blocked = await prisma.customer.update({
where: { id },
data: { blockedAt: new Date(), sessionVersion: { increment: 1 } },
});
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
revalidatePath("/admin/benutzer");
}
export async function unblockCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const unblocked = await prisma.customer.update({
where: { id },
data: { blockedAt: null, failedLoginAttempts: 0, lockedUntil: null },
});
await logAdminAction(admin, {
action: "customer.unblock",
entityType: "Customer",
entityId: id,
detail: unblocked.email,
});
revalidatePath("/admin/benutzer");
}
export async function deleteCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
if (id === admin.id) {
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
}
if (await isLastAdmin(id)) {
fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gelöscht werden.");
}
const deleted = await prisma.customer.delete({ where: { id } });
await logAdminAction(admin, { action: "customer.delete", entityType: "Customer", entityId: id, detail: deleted.email });
revalidatePath("/admin/benutzer");
redirect("/admin/benutzer");
}
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
export async function sendCustomerPasswordResetAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const customer = await prisma.customer.findUnique({ where: { id } });
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
await createAndSendPasswordResetEmail(customer);
await logAdminAction(admin, {
action: "customer.password_reset_sent",
entityType: "Customer",
entityId: id,
detail: customer.email,
});
redirect(`/admin/benutzer/${id}?message=` + encodeURIComponent("Passwort-Reset-E-Mail wurde verschickt."));
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { discountCodeSchema } from "@/lib/validation";
import { requireAdmin, fail, str } from "./_shared";
function parseDiscountCodeForm(formData: FormData) {
const rawMax = str(formData, "maxUses");
const rawMinQuantity = str(formData, "minQuantity");
const rawFrom = str(formData, "validFrom");
const rawUntil = str(formData, "validUntil");
return discountCodeSchema.safeParse({
code: str(formData, "code"),
type: str(formData, "type"),
value: str(formData, "value"),
minOrderCents: str(formData, "minOrderCents") || "0",
minQuantity: rawMinQuantity === "" ? undefined : rawMinQuantity,
maxUses: rawMax === "" ? undefined : rawMax,
isActive: formData.get("isActive") === "on",
validFrom: rawFrom === "" ? undefined : rawFrom,
validUntil: rawUntil === "" ? undefined : rawUntil,
});
}
export async function createDiscountCodeAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) {
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
if (existing) {
fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
}
const code = await prisma.discountCode.create({ data: parsed.data });
await logAdminAction(admin, {
action: "discount_code.create",
entityType: "DiscountCode",
entityId: code.id,
detail: code.code,
});
revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine");
}
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) {
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const existing = await prisma.discountCode.findUnique({ where: { code: parsed.data.code } });
if (existing && existing.id !== id) {
fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
}
await prisma.discountCode.update({ where: { id }, data: parsed.data });
await logAdminAction(admin, {
action: "discount_code.update",
entityType: "DiscountCode",
entityId: id,
detail: parsed.data.code,
});
revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine");
}
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const deleted = await prisma.discountCode.delete({ where: { id } });
await logAdminAction(admin, {
action: "discount_code.delete",
entityType: "DiscountCode",
entityId: id,
detail: deleted.code,
});
revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine");
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str, num } from "./_shared";
export async function createHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const year = num(formData, "year");
const type = str(formData, "type");
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
const harvest = await prisma.harvest.create({
data: {
year,
type,
slug,
harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"),
},
});
await logAdminAction(admin, {
action: "harvest.create",
entityType: "Harvest",
entityId: harvest.id,
detail: `${type} ${year}`,
});
revalidatePath("/admin/ernten");
redirect("/admin/ernten");
}
export async function updateHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
await prisma.harvest.update({
where: { id },
data: {
year: num(formData, "year"),
type: str(formData, "type"),
harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"),
},
});
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
revalidatePath("/admin/ernten");
redirect("/admin/ernten");
}
export async function createBatchAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const code = str(formData, "code");
const batch = await prisma.batch.create({
data: {
code,
harvestId: str(formData, "harvestId"),
fillDate: new Date(str(formData, "fillDate")),
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"),
},
});
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
revalidatePath("/admin/chargen");
redirect("/admin/chargen");
}
export async function updateBatchAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
await prisma.batch.update({
where: { id },
data: {
fillDate: new Date(str(formData, "fillDate")),
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"),
},
});
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
revalidatePath("/admin/chargen");
redirect("/admin/chargen");
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str } from "./_shared";
export async function updateBusinessInquiryStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const status = str(formData, "status");
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return;
await prisma.businessInquiry.update({
where: { id },
data: { status: status as "NEU" | "IN_BEARBEITUNG" | "ABGESCHLOSSEN" | "ABGELEHNT" },
});
await logAdminAction(admin, {
action: "business_inquiry.status_change",
entityType: "BusinessInquiry",
entityId: id,
detail: status,
});
revalidatePath("/admin/anfragen");
}
+164
View File
@@ -0,0 +1,164 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { shipmentEmail, pickupReadyEmail } from "@/lib/email-templates";
import { getCarrierTrackingUrl } from "@/lib/shipment-tracking";
import { restockOrderItems } from "@/lib/inventory";
import { isLocale, defaultLocale } from "@/i18n/config";
import { requireAdmin, str, strOrNull } from "./_shared";
const ORDER_STATUSES = [
"NEU",
"BEZAHLT",
"IN_VORBEREITUNG",
"VERSANDBEREIT",
"VERSENDET",
"ABHOLBEREIT",
"ABGEHOLT",
"STORNIERT",
] as const;
export async function updateOrderStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
if (!ORDER_STATUSES.includes(status)) return;
const result = await prisma.$transaction(async (tx) => {
const previous = await tx.order.findUnique({
where: { id: orderId },
select: {
status: true,
orderNumber: true,
email: true,
firstName: true,
locale: true,
items: { select: { productId: true, quantity: true } },
},
});
if (!previous) return null;
await tx.order.update({ where: { id: orderId }, data: { status } });
// Beim Checkout reservierter Bestand wird bei Stornierung zurueckgebucht
// (genau einmal — nur beim Wechsel VON einem nicht-stornierten Status).
if (status === "STORNIERT" && previous.status !== "STORNIERT") {
await restockOrderItems(tx, previous.items);
}
let shipment: { carrier: string | null; trackingNumber: string | null } | null = null;
if (status === "VERSENDET") {
shipment = await tx.shipment.upsert({
where: { orderId },
create: { orderId, shippedAt: new Date() },
update: { shippedAt: new Date() },
select: { carrier: true, trackingNumber: true },
});
}
if (status === "ABHOLBEREIT") {
await tx.pickup.upsert({
where: { orderId },
create: { orderId, readyAt: new Date() },
update: { readyAt: new Date() },
});
}
if (status === "ABGEHOLT") {
await tx.pickup.upsert({
where: { orderId },
create: { orderId, pickedUpAt: new Date() },
update: { pickedUpAt: new Date() },
});
}
return { ...previous, shipment };
});
if (result) {
await logAdminAction(admin, {
action: "order.status_change",
entityType: "Order",
entityId: orderId,
detail: `${result.orderNumber}: ${result.status} → ${status}`,
});
// E-Mail-Benachrichtigung bei Versand/Abholbereitschaft — bewusst nach
// der Transaktion, ein Mail-Ausfall darf die Statusaenderung nicht
// rueckgaengig machen (sendMail wirft nie, siehe lib/mail.ts).
const locale = isLocale(result.locale) ? result.locale : defaultLocale;
if (status === "VERSENDET" && result.status !== "VERSENDET") {
const carrier = result.shipment?.carrier ?? null;
const trackingNumber = result.shipment?.trackingNumber ?? null;
const { subject, html, text } = await shipmentEmail({
locale,
orderNumber: result.orderNumber,
firstName: result.firstName,
carrier,
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
await sendMail({ to: result.email, subject, html, text });
}
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
const { subject, html, text } = await pickupReadyEmail({
locale,
orderNumber: result.orderNumber,
firstName: result.firstName,
});
await sendMail({ to: result.email, subject, html, text });
}
}
revalidatePath("/admin/bestellungen");
}
/**
* Erfasst Versanddienstleister + Sendungsnummer fuer eine Bestellung —
* unabhaengig vom Status setzbar, da die Nummer oft schon vor dem
* eigentlichen Versandscan vom Versanddienstleister vergeben wird. Ist die
* Bestellung bereits als VERSENDET markiert, wird die Sendungsverfolgung
* per separater E-Mail nachgereicht statt nur in der ersten Versand-Mail zu
* stehen (die zu diesem Zeitpunkt schon verschickt war).
*/
export async function updateShipmentTrackingAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const orderId = str(formData, "orderId");
const carrier = strOrNull(formData, "carrier");
const trackingNumber = strOrNull(formData, "trackingNumber");
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
});
if (!order) return;
await prisma.shipment.upsert({
where: { orderId },
create: { orderId, carrier, trackingNumber },
update: { carrier, trackingNumber },
});
await logAdminAction(admin, {
action: "order.tracking_update",
entityType: "Order",
entityId: orderId,
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
});
if (order.status === "VERSENDET" && carrier && trackingNumber) {
const { subject, html, text } = await shipmentEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale,
orderNumber: order.orderNumber,
firstName: order.firstName,
carrier,
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
await sendMail({ to: order.email, subject, html, text });
}
revalidatePath("/admin/bestellungen");
revalidatePath(`/admin/bestellungen/${orderId}`);
}
+125
View File
@@ -0,0 +1,125 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation";
import { requireAdmin, fail, str, strOrNull } from "./_shared";
export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-");
const parsedNumbers = adminProductNumbersSchema.safeParse({
intensityMild: str(formData, "intensityMild"),
intensityFloral: str(formData, "intensityFloral"),
intensityKraeftig: str(formData, "intensityKraeftig"),
weightGrams: str(formData, "weightGrams"),
priceCents: str(formData, "priceCents"),
pricePerKgCents: str(formData, "pricePerKgCents"),
quantity: str(formData, "quantity"),
});
if (!parsedNumbers.success) {
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const numbers = parsedNumbers.data;
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
const isHoney = category === "HONIG";
const product = await prisma.product.create({
data: {
slug,
name: str(formData, "name"),
category,
harvestId: isHoney ? strOrNull(formData, "harvestId") : null,
batchId: isHoney ? strOrNull(formData, "batchId") : null,
description: str(formData, "description"),
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
intensityMild: numbers.intensityMild,
intensityFloral: numbers.intensityFloral,
intensityKraeftig: numbers.intensityKraeftig,
consistency: str(formData, "consistency") || "feincremig",
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
isActive: formData.get("isActive") === "on",
inventory: { create: { quantity: numbers.quantity } },
},
});
await logAdminAction(admin, {
action: "product.create",
entityType: "Product",
entityId: product.id,
detail: product.name,
});
revalidatePath("/admin/produkte");
redirect("/admin/produkte");
}
export async function updateProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const parsedNumbers = adminProductNumbersSchema.safeParse({
intensityMild: str(formData, "intensityMild"),
intensityFloral: str(formData, "intensityFloral"),
intensityKraeftig: str(formData, "intensityKraeftig"),
weightGrams: str(formData, "weightGrams"),
priceCents: str(formData, "priceCents"),
pricePerKgCents: str(formData, "pricePerKgCents"),
quantity: str(formData, "quantity"),
});
if (!parsedNumbers.success) {
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const numbers = parsedNumbers.data;
const previousInventory = await prisma.inventory.findUnique({
where: { productId: id },
select: { quantity: true },
});
const product = await prisma.product.update({
where: { id },
data: {
name: str(formData, "name"),
description: str(formData, "description"),
tasteTags: str(formData, "tasteTags").split(",").map((t) => t.trim()).filter(Boolean),
intensityMild: numbers.intensityMild,
intensityFloral: numbers.intensityFloral,
intensityKraeftig: numbers.intensityKraeftig,
consistency: str(formData, "consistency") || "feincremig",
weightGrams: numbers.weightGrams,
priceCents: numbers.priceCents,
pricePerKgCents: numbers.pricePerKgCents,
isActive: formData.get("isActive") === "on",
inventory: {
upsert: { create: { quantity: numbers.quantity }, update: { quantity: numbers.quantity } },
},
},
});
await logAdminAction(admin, {
action: "product.update",
entityType: "Product",
entityId: id,
detail: product.name,
});
// Bestandsauffrischung: war zuvor ausverkauft (0 oder kein Datensatz) und
// ist jetzt wieder verfuegbar -> wartende Benachrichtigungen versenden.
if ((previousInventory?.quantity ?? 0) === 0 && numbers.quantity > 0) {
await notifyStockAvailable(id);
}
revalidatePath("/admin/produkte");
redirect("/admin/produkte");
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str } from "./_shared";
export async function approveReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const review = await prisma.review.update({
where: { id },
data: { isApproved: true },
select: { product: { select: { slug: true, name: true } } },
});
await logAdminAction(admin, {
action: "review.approve",
entityType: "Review",
entityId: id,
detail: review.product.name,
});
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
}
export async function deleteReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const review = await prisma.review.delete({
where: { id },
select: { product: { select: { slug: true, name: true } } },
});
await logAdminAction(admin, {
action: "review.delete",
entityType: "Review",
entityId: id,
detail: review.product.name,
});
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
}
+86
View File
@@ -0,0 +1,86 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { shippingTierSchema } from "@/lib/validation";
import { requireAdmin, fail, str } from "./_shared";
function parseShippingTierForm(formData: FormData) {
const rawMax = str(formData, "maxQuantity");
return shippingTierSchema.safeParse({
minQuantity: str(formData, "minQuantity"),
maxQuantity: rawMax === "" ? undefined : rawMax,
priceCents: str(formData, "priceCents"),
});
}
export async function createShippingTierAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const parsed = parseShippingTierForm(formData);
if (!parsed.success) {
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const tier = await prisma.shippingTier.create({
data: {
minQuantity: parsed.data.minQuantity,
maxQuantity: parsed.data.maxQuantity ?? null,
priceCents: parsed.data.priceCents,
},
});
await logAdminAction(admin, {
action: "shipping_tier.create",
entityType: "ShippingTier",
entityId: tier.id,
detail: `ab ${tier.minQuantity} Stück: ${tier.priceCents} Cent`,
});
revalidatePath("/admin/versand");
revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung");
redirect("/admin/versand");
}
export async function updateShippingTierAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const parsed = parseShippingTierForm(formData);
if (!parsed.success) {
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
await prisma.shippingTier.update({
where: { id },
data: {
minQuantity: parsed.data.minQuantity,
maxQuantity: parsed.data.maxQuantity ?? null,
priceCents: parsed.data.priceCents,
},
});
await logAdminAction(admin, { action: "shipping_tier.update", entityType: "ShippingTier", entityId: id });
revalidatePath("/admin/versand");
revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung");
redirect("/admin/versand");
}
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
await prisma.shippingTier.delete({ where: { id } });
await logAdminAction(admin, { action: "shipping_tier.delete", entityType: "ShippingTier", entityId: id });
revalidatePath("/admin/versand");
revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung");
redirect("/admin/versand");
}
+117
View File
@@ -0,0 +1,117 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format";
import { updateBusinessInquiryStatusAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
const t = getDictionary(await getLocale());
const statusLabels: Record<string, string> = {
NEU: t.adminInquiriesPage.statusNew,
IN_BEARBEITUNG: t.adminInquiriesPage.statusInProgress,
ABGESCHLOSSEN: t.adminInquiriesPage.statusCompleted,
ABGELEHNT: t.adminInquiriesPage.statusRejected,
};
const { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const inquiries = await prisma.businessInquiry.findMany({
where: query
? {
OR: [
{ companyName: insensitiveContains(query) },
{ contactPerson: insensitiveContains(query) },
{ email: insensitiveContains(query) },
],
}
: undefined,
orderBy: { createdAt: "desc" },
take: 100,
});
return (
<div>
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.title}</h1>
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminInquiriesPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
/>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminInquiriesPage.filterButton}
</button>
{query ? (
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
{t.adminInquiriesPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4">
{inquiries.map((inquiry) => (
<div key={inquiry.id} className="bg-background border border-ink/10 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-forest">{inquiry.companyName}</p>
<p className="text-sm text-ink/70">
{inquiry.contactPerson} · {inquiry.email}
{inquiry.phone ? ` · ${inquiry.phone}` : ""}
</p>
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p>
</div>
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
<input type="hidden" name="id" value={inquiry.id} />
<select
name="status"
defaultValue={inquiry.status}
className="border border-ink/20 bg-background px-2 py-1 text-xs"
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<button type="submit" className="text-xs text-forest underline">
{t.adminInquiriesPage.save}
</button>
</form>
</div>
<dl className="mt-4 grid sm:grid-cols-3 gap-4 text-sm">
<div>
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.packageLabel}</dt>
<dd>{inquiry.package}</dd>
</div>
<div>
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.quantityLabel}</dt>
<dd>{t.adminInquiriesPage.unitsSuffix(inquiry.desiredQuantity)}</dd>
</div>
<div>
<dt className="text-ink/65 text-xs">{t.adminInquiriesPage.deliveryDateLabel}</dt>
<dd>{inquiry.desiredDeliveryDate ? formatDate(inquiry.desiredDeliveryDate) : "—"}</dd>
</div>
</dl>
{inquiry.message ? (
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{inquiry.message}</p>
) : null}
</div>
))}
{inquiries.length === 0 ? (
<p className="text-ink/65">
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
</p>
) : null}
</div>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatDateTime } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
const RECENT_LIMIT = 200;
export default async function AdminAuditLogPage() {
const t = getDictionary(await getLocale());
const entries = await prisma.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: RECENT_LIMIT,
});
return (
<div>
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1>
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminAuditLogPage.colTimestamp}</th>
<th className="p-4">{t.adminAuditLogPage.colAdmin}</th>
<th className="p-4">{t.adminAuditLogPage.colAction}</th>
<th className="p-4">{t.adminAuditLogPage.colObject}</th>
<th className="p-4">{t.adminAuditLogPage.colDetails}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{entries.map((entry) => (
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td>
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
<td className="p-4 whitespace-nowrap text-ink/70">
{entry.entityType}
{entry.entityId ? <span className="text-ink/65"> #{entry.entityId.slice(-6)}</span> : null}
</td>
<td className="p-4 max-w-sm">{entry.detail ?? "—"}</td>
</tr>
))}
</tbody>
</table>
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null}
</div>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format";
import {
updateCustomerAction,
blockCustomerAction,
unblockCustomerAction,
deleteCustomerAction,
sendCustomerPasswordResetAction,
} from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const { error, message } = await props.searchParams;
const [admin, customer] = await Promise.all([
getCurrentAdmin(),
prisma.customer.findUnique({ where: { id } }),
]);
if (!customer) notFound();
const isSelf = customer.id === admin?.id;
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
return (
<div className="max-w-xl">
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} />
<h1 className="text-2xl text-forest mb-2">
{customer.firstName} {customer.lastName}
</h1>
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={customer.id} />
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required />
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required />
</div>
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required />
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} />
<label className="block text-sm">
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
<select
name="role"
defaultValue={customer.role}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
>
<option value="KUNDE">{t.adminUserEditPage.roleCustomer}</option>
<option value="ADMIN">{t.adminUserEditPage.roleAdmin}</option>
</select>
{isSelf ? (
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span>
) : null}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminUserEditPage.save}
</button>
</form>
<div className="mt-6 bg-background border border-ink/10 p-6 space-y-4">
<h2 className="text-sm text-ink/70">{t.adminUserEditPage.securityHeading}</h2>
<dl className="text-sm space-y-1">
<div className="flex justify-between">
<dt className="text-ink/65">{t.adminUserEditPage.twoFactorLabel}</dt>
<dd>{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink/65">{t.adminUserEditPage.statusLabel}</dt>
<dd>
{customer.blockedAt ? (
<span className="text-red-700">
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
</span>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
) : (
<span className="text-forest">{t.adminUserEditPage.statusActive}</span>
)}
</dd>
</div>
</dl>
<form action={sendCustomerPasswordResetAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminUserEditPage.sendPasswordReset}
</button>
</form>
{!isSelf ? (
<div className="flex items-center gap-4 pt-2 border-t border-ink/10">
{customer.blockedAt ? (
<form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline text-sm">
{t.adminUserEditPage.unblock}
</button>
</form>
) : (
<form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline text-sm">
{t.adminUserEditPage.block}
</button>
</form>
)}
<ConfirmForm
action={deleteCustomerAction}
confirmMessage={t.adminUserEditPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline text-sm">
{t.adminUserEditPage.deleteAccount}
</button>
</ConfirmForm>
</div>
) : null}
</div>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { BackLink } from "@/components/back-link";
import { createCustomerAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benutzer/neu">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return (
<div className="max-w-xl">
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} />
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required />
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required />
</div>
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required />
<label className="block text-sm">
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span>
<select
name="role"
defaultValue="KUNDE"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
>
<option value="KUNDE">{t.adminUserNewPage.roleCustomer}</option>
<option value="ADMIN">{t.adminUserNewPage.roleAdmin}</option>
</select>
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminUserNewPage.createAndSend}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
required = false,
}: {
name: string;
label: string;
type?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+158
View File
@@ -0,0 +1,158 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format";
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerPage(props: PageProps<"/admin/benutzer">) {
const t = getDictionary(await getLocale());
const roleLabels: Record<string, string> = { KUNDE: t.adminUsersPage.roleCustomer, ADMIN: t.adminUsersPage.roleAdmin };
const { error, message, q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const [admin, customers] = await Promise.all([
getCurrentAdmin(),
prisma.customer.findMany({
where: query
? {
OR: [
{ firstName: insensitiveContains(query) },
{ lastName: insensitiveContains(query) },
{ email: insensitiveContains(query) },
],
}
: undefined,
orderBy: { createdAt: "desc" },
take: 100,
}),
]);
return (
<div>
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminUsersPage.newUser}
</Link>
</div>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminUsersPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
/>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminUsersPage.filterButton}
</button>
{query ? (
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
{t.adminUsersPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminUsersPage.colName}</th>
<th className="p-4">{t.adminUsersPage.colEmail}</th>
<th className="p-4">{t.adminUsersPage.colRole}</th>
<th className="p-4">{t.adminUsersPage.colStatus}</th>
<th className="p-4">{t.adminUsersPage.col2fa}</th>
<th className="p-4">{t.adminUsersPage.colCreated}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{customers.map((customer) => {
const isSelf = customer.id === admin?.id;
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
return (
<tr key={customer.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">
{customer.firstName} {customer.lastName}
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
</td>
<td className="p-4">{customer.email}</td>
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
<td className="p-4">
{customer.blockedAt ? (
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
) : (
<span className="text-forest">{t.adminUsersPage.statusActive}</span>
)}
</td>
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
<td className="p-4">{formatDate(customer.createdAt)}</td>
<td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap">
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline">
{t.adminUsersPage.edit}
</Link>
{!isSelf ? (
customer.blockedAt ? (
<form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline">
{t.adminUsersPage.unblock}
</button>
</form>
) : (
<form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline">
{t.adminUsersPage.block}
</button>
</form>
)
) : null}
{!isSelf ? (
<ConfirmForm
action={deleteCustomerAction}
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline">
{t.adminUsersPage.delete}
</button>
</ConfirmForm>
) : null}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{customers.length === 0 ? (
<p className="p-6 text-ink/65">
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
</p>
) : null}
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice, formatDate } from "@/lib/format";
import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { id } = await props.params;
const order = await prisma.order.findUnique({
where: { id },
include: {
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
payment: true,
shipment: true,
pickup: true,
invoice: true,
customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } },
},
});
if (!order) notFound();
return (
<div className="max-w-3xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
</div>
<form action={updateOrderStatusAction} className="flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
name="status"
defaultValue={order.status}
className="border border-ink/20 bg-background px-3 py-2 text-sm"
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.saveStatus}
</button>
</form>
</div>
{order.giftMessage ? (
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
<p className="whitespace-pre-wrap text-ink">{order.giftMessage}</p>
</div>
) : null}
<div className="grid sm:grid-cols-2 gap-6 mb-8">
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.customerHeading}</h2>
<p>{order.firstName} {order.lastName}</p>
<p className="text-ink/70">{order.email}</p>
{order.phone ? <p className="text-ink/70">{order.phone}</p> : null}
{order.customer ? (
<p className="mt-2 text-xs text-honey-dark">{t.adminOrderDetailPage.registeredAccount}</p>
) : (
<p className="mt-2 text-xs text-ink/65">{t.adminOrderDetailPage.guestOrder}</p>
)}
</div>
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">
{order.fulfillmentMethod === "VERSAND"
? t.adminOrderDetailPage.shippingAddressHeading
: t.adminOrderDetailPage.pickupHeading}
</h2>
{order.fulfillmentMethod === "VERSAND" ? (
<>
<p>{order.street}</p>
<p>{order.zip} {order.city}</p>
<p className="mt-2 text-xs text-ink/65">
{order.shipment?.shippedAt
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
: t.adminOrderDetailPage.notShippedYet}
</p>
</>
) : (
<p className="text-xs text-ink/65">
{order.pickup?.pickedUpAt
? t.adminOrderDetailPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
: order.pickup?.readyAt
? t.adminOrderDetailPage.readySince(formatDate(order.pickup.readyAt))
: t.adminOrderDetailPage.notPickedUpYet}
</p>
)}
</div>
</div>
{order.fulfillmentMethod === "VERSAND" ? (
<div className="bg-background border border-ink/10 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
<form action={updateShipmentTrackingAction} className="flex flex-wrap items-end gap-4">
<input type="hidden" name="orderId" value={order.id} />
<label className="block text-sm">
<span className="text-ink/70">{t.adminOrderDetailPage.carrierLabel}</span>
<input
name="carrier"
defaultValue={order.shipment?.carrier ?? ""}
placeholder={t.adminOrderDetailPage.carrierPlaceholder}
className="mt-1 w-48 border border-ink/20 bg-background px-3 py-2 text-sm"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminOrderDetailPage.trackingNumberLabel}</span>
<input
name="trackingNumber"
defaultValue={order.shipment?.trackingNumber ?? ""}
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
/>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.saveTracking}
</button>
</form>
</div>
) : null}
<div className="bg-background border border-ink/10 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.itemsHeading}</h2>
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="pb-2">{t.adminOrderDetailPage.colProduct}</th>
<th className="pb-2">{t.adminOrderDetailPage.colBatch}</th>
<th className="pb-2 text-right">{t.adminOrderDetailPage.colQuantity}</th>
<th className="pb-2 text-right">{t.adminOrderDetailPage.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{order.items.map((item) => (
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="py-2">
<Link href={`/admin/produkte/${item.productId}`} className="text-forest underline">
{item.productName}
</Link>{" "}
<span className="text-ink/65">({item.weightGrams} g)</span>
</td>
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right">{formatPrice(item.totalCents, order.currency)}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex justify-end">
<div className="w-56 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-ink/70">{t.adminOrderDetailPage.subtotalLabel}</span>
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.adminOrderDetailPage.shippingLabel}</span>
<span>
{order.shippingCents === 0
? t.adminOrderDetailPage.freeShipping
: formatPrice(order.shippingCents, order.currency)}
</span>
</div>
{order.discountCents > 0 ? (
<div className="flex justify-between">
<span className="text-ink/70">
{t.adminOrderDetailPage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
</span>
<span>−{formatPrice(order.discountCents, order.currency)}</span>
</div>
) : null}
<div className="flex justify-between text-forest pt-1 border-t border-ink/10">
<span>{t.adminOrderDetailPage.totalLabel}</span>
<span>{formatPrice(order.totalCents, order.currency)}</span>
</div>
</div>
</div>
</div>
<div className="grid sm: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-3">{t.adminOrderDetailPage.paymentHeading}</h2>
{order.payment ? (
<>
<p>{order.payment.status}</p>
<p className="text-ink/70">{formatPrice(order.payment.amountCents, order.payment.currency)}</p>
{order.payment.paidAt ? (
<p className="text-xs text-ink/65 mt-1">
{t.adminOrderDetailPage.paidOn(formatDate(order.payment.paidAt))}
</p>
) : null}
</>
) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noPaymentYet}</p>
)}
</div>
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.invoiceHeading}</h2>
{order.invoice ? (
<div className="flex flex-col gap-1">
<span>{order.invoice.invoiceNumber}</span>
<a
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
className="text-forest underline text-sm"
>
{t.adminOrderDetailPage.downloadPdf}
</a>
</div>
) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
)}
</div>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice, formatDate } from "@/lib/format";
import { OrderStatusBadge } from "@/components/account-ui";
import { updateOrderStatusAction } from "../actions";
import type { OrderStatus } from "@/generated/prisma/client";
import { insensitiveContains } from "@/lib/prisma-search";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { q, status } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
const orders = await prisma.order.findMany({
where: {
...(statusFilter ? { status: statusFilter } : {}),
...(query
? {
OR: [
{ orderNumber: insensitiveContains(query) },
{ email: insensitiveContains(query) },
{ lastName: insensitiveContains(query) },
],
}
: {}),
},
orderBy: { createdAt: "desc" },
take: 100,
include: { items: true },
});
return (
<div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1>
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminOrdersPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
/>
</label>
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
<select
name="status"
defaultValue={statusFilter ?? ""}
className="border border-ink/20 bg-background px-3 py-2 text-sm"
>
<option value="">{t.adminOrdersPage.statusAll}</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminOrdersPage.filterButton}
</button>
{query || statusFilter ? (
<Link href="/admin/bestellungen" className="text-sm text-ink/65 underline">
{t.adminOrdersPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminOrdersPage.colNumber}</th>
<th className="p-4">{t.adminOrdersPage.colCustomer}</th>
<th className="p-4">{t.adminOrdersPage.colDate}</th>
<th className="p-4">{t.adminOrdersPage.colItems}</th>
<th className="p-4">{t.adminOrdersPage.colTotal}</th>
<th className="p-4">{t.adminOrdersPage.colStatus}</th>
<th className="p-4">{t.adminOrdersPage.colFulfillment}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{orders.map((order) => (
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">
<Link href={`/admin/bestellungen/${order.id}`} className="underline">
{order.orderNumber}
</Link>
{order.giftMessage ? (
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}>
🎁
</span>
) : null}
</td>
<td className="p-4">
{order.firstName} {order.lastName}
<br />
<span className="text-ink/65 text-xs">{order.email}</span>
</td>
<td className="p-4">{formatDate(order.createdAt)}</td>
<td className="p-4">{order.items.length}</td>
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td>
<td className="p-4">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
<input type="hidden" name="orderId" value={order.id} />
<select
name="status"
defaultValue={order.status}
className="border border-ink/20 bg-background px-2 py-1 text-xs"
>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<button type="submit" className="text-xs text-forest underline">
{t.adminOrdersPage.save}
</button>
</form>
</td>
<td className="p-4">
{order.fulfillmentMethod === "VERSAND" ? t.adminOrdersPage.shipping : t.adminOrdersPage.pickup}
</td>
</tr>
))}
</tbody>
</table>
{orders.length === 0 ? (
<p className="p-6 text-ink/65">
{query || statusFilter ? t.adminOrdersPage.noResultsFiltered : t.adminOrdersPage.noOrdersYet}
</p>
) : null}
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format";
import { insensitiveContains } from "@/lib/prisma-search";
import { approveReviewAction, deleteReviewAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBewertungenPage(props: PageProps<"/admin/bewertungen">) {
const t = getDictionary(await getLocale());
const { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const reviews = await prisma.review.findMany({
where: query
? {
OR: [
{ product: { name: insensitiveContains(query) } },
{ customer: { firstName: insensitiveContains(query) } },
{ customer: { lastName: insensitiveContains(query) } },
],
}
: undefined,
include: {
product: { select: { name: true, slug: true } },
customer: { select: { firstName: true, lastName: true } },
},
orderBy: [{ isApproved: "asc" }, { createdAt: "desc" }],
take: 100,
});
return (
<div>
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReviewsPage.title}</h1>
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminReviewsPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
/>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminReviewsPage.filterButton}
</button>
{query ? (
<Link href="/admin/bewertungen" className="text-sm text-ink/65 underline">
{t.adminReviewsPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminReviewsPage.colProduct}</th>
<th className="p-4">{t.adminReviewsPage.colCustomer}</th>
<th className="p-4">{t.adminReviewsPage.colRating}</th>
<th className="p-4">{t.adminReviewsPage.colComment}</th>
<th className="p-4">{t.adminReviewsPage.colDate}</th>
<th className="p-4">{t.adminReviewsPage.colStatus}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{reviews.map((review) => (
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4">
<Link href={`/honig/${review.product.slug}`} className="text-forest underline">
{review.product.name}
</Link>
</td>
<td className="p-4">
{review.customer.firstName} {review.customer.lastName}
</td>
<td className="p-4 text-honey-dark whitespace-nowrap">
{"★".repeat(review.rating)}
{"☆".repeat(5 - review.rating)}
</td>
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
<td className="p-4">
{review.isApproved ? (
<span className="text-forest">{t.adminReviewsPage.statusPublished}</span>
) : (
<span className="text-honey-dark">{t.adminReviewsPage.statusPending}</span>
)}
</td>
<td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap">
{!review.isApproved ? (
<form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} />
<button type="submit" className="text-forest underline">
{t.adminReviewsPage.approve}
</button>
</form>
) : null}
<ConfirmForm
action={deleteReviewAction}
confirmMessage={t.adminReviewsPage.deleteConfirm}
>
<input type="hidden" name="id" value={review.id} />
<button type="submit" className="text-red-700 underline">
{t.adminReviewsPage.delete}
</button>
</ConfirmForm>
</div>
</td>
</tr>
))}
</tbody>
</table>
{reviews.length === 0 ? (
<p className="p-6 text-ink/65">
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
</p>
) : null}
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { updateBatchAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const batch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
if (!batch) notFound();
return (
<div className="max-w-xl">
<BackLink href="/admin/chargen" label={t.adminBatchEditPage.backToBatches} />
<h1 className="text-2xl text-forest mb-2">{t.adminBatchEditPage.title}</h1>
<p className="text-sm text-ink/65 mb-8">
{batch.code} · {batch.harvest.type} {batch.harvest.year}
</p>
<form action={updateBatchAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={batch.id} />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.fillDateLabel}</span>
<input
name="fillDate"
type="date"
defaultValue={batch.fillDate.toISOString().slice(0, 10)}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.bestBeforeDateLabel}</span>
<input
name="bestBeforeDate"
type="date"
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.glassCountLabel}</span>
<input
name="glassCount"
type="number"
defaultValue={batch.glassCount}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.remainingGlassesLabel}</span>
<input
name="remainingGlasses"
type="number"
defaultValue={batch.remainingGlasses}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
</div>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchEditPage.save}
</button>
</form>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format";
import { createBatchAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchesPage() {
const t = getDictionary(await getLocale());
const [batches, harvests] = await Promise.all([
prisma.batch.findMany({
orderBy: { fillDate: "desc" },
include: { harvest: true },
}),
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
]);
return (
<div>
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminBatchesPage.colBatch}</th>
<th className="p-4">{t.adminBatchesPage.colHarvest}</th>
<th className="p-4">{t.adminBatchesPage.colFillDate}</th>
<th className="p-4">{t.adminBatchesPage.colBestBefore}</th>
<th className="p-4">{t.adminBatchesPage.colTotalGlasses}</th>
<th className="p-4">{t.adminBatchesPage.colRemaining}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{batches.map((batch) => (
<tr key={batch.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{batch.code}</td>
<td className="p-4">
{batch.harvest.type} {batch.harvest.year}
</td>
<td className="p-4">{formatDate(batch.fillDate)}</td>
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td>
<td className="p-4">{batch.glassCount}</td>
<td className="p-4">{batch.remainingGlasses}</td>
<td className="p-4">
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline">
{t.adminBatchesPage.edit}
</Link>
</td>
</tr>
))}
</tbody>
</table>
{batches.length === 0 ? <p className="p-6 text-ink/65">{t.adminBatchesPage.noBatchesYet}</p> : null}
</div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
<h2 className="text-lg text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
<form action={createBatchAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
<input
name="code"
required
placeholder={t.adminBatchesPage.codePlaceholder}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.harvestLabel}</span>
<select name="harvestId" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
{harvests.map((h) => (
<option key={h.id} value={h.id}>
{h.type} {h.year}
</option>
))}
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span>
<input name="fillDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.bestBeforeDateLabel}</span>
<input name="bestBeforeDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span>
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span>
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
</div>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchesPage.create}
</button>
</form>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { updateHarvestAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const harvest = await prisma.harvest.findUnique({ where: { id } });
if (!harvest) notFound();
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
return (
<div className="max-w-xl">
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={harvest.id} />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.typeLabel}</span>
<input
name="type"
defaultValue={harvest.type}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.yearLabel}</span>
<input
name="year"
type="number"
defaultValue={harvest.year}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
</div>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.harvestDateLabel}</span>
<input
name="harvestDate"
type="date"
defaultValue={harvestDateValue}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.locationLabel}</span>
<input
name="location"
defaultValue={harvest.location}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.descriptionLabel}</span>
<textarea
name="description"
defaultValue={harvest.description}
required
rows={4}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestEditPage.save}
</button>
</form>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format";
import { createHarvestAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage() {
const t = getDictionary(await getLocale());
const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } },
});
return (
<div>
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminHarvestsPage.colType}</th>
<th className="p-4">{t.adminHarvestsPage.colYear}</th>
<th className="p-4">{t.adminHarvestsPage.colHarvestDate}</th>
<th className="p-4">{t.adminHarvestsPage.colLocation}</th>
<th className="p-4">{t.adminHarvestsPage.colBatches}</th>
<th className="p-4">{t.adminHarvestsPage.colProducts}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{harvests.map((harvest) => (
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{harvest.type}</td>
<td className="p-4">{harvest.year}</td>
<td className="p-4">{formatDate(harvest.harvestDate)}</td>
<td className="p-4">{harvest.location}</td>
<td className="p-4">{harvest._count.batches}</td>
<td className="p-4">{harvest._count.products}</td>
<td className="p-4">
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline">
{t.adminHarvestsPage.edit}
</Link>
</td>
</tr>
))}
</tbody>
</table>
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
</div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
<form action={createHarvestAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
</div>
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestsPage.create}
</button>
</form>
</div>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const { t } = useI18n();
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="py-20 text-center">
<p className="tracking-wide-caps text-xs text-honey-dark mb-3">{t.errorPage.eyebrow}</p>
<h1 className="text-2xl text-forest">{t.errorPage.title}</h1>
<p className="mt-3 text-ink/70">{t.errorPage.description}</p>
<div className="mt-6 flex items-center justify-center gap-4">
<Button type="button" onClick={() => reset()}>
{t.errorPage.retry}
</Button>
<Link href="/admin" className="text-sm text-forest underline">
{t.adminVouchersPage.backToDashboard}
</Link>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { updateDiscountCodeAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
function toDateInputValue(date: Date | null): string {
if (!date) return "";
return date.toISOString().slice(0, 10);
}
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const { error } = await props.searchParams;
const code = await prisma.discountCode.findUnique({ where: { id } });
if (!code) notFound();
return (
<div className="max-w-md">
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={code.id} />
<Field name="code" label={t.adminVoucherEditPage.codeLabel} defaultValue={code.code} required />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminVoucherEditPage.typeLabel}</span>
<select
name="type"
defaultValue={code.type}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
>
<option value="PERCENT">{t.adminVoucherEditPage.typePercent}</option>
<option value="FIXED">{t.adminVoucherEditPage.typeFixed}</option>
</select>
</label>
<Field name="value" label={t.adminVoucherEditPage.valueLabel} type="number" defaultValue={String(code.value)} required />
</div>
<Field
name="minOrderCents"
label={t.adminVoucherEditPage.minOrderLabel}
type="number"
defaultValue={String(code.minOrderCents)}
/>
<Field
name="minQuantity"
label={t.adminVoucherEditPage.minQuantityLabel}
type="number"
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
/>
<Field
name="maxUses"
label={t.adminVoucherEditPage.maxUsesLabel}
type="number"
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
/>
<div className="grid grid-cols-2 gap-4">
<Field
name="validFrom"
label={t.adminVoucherEditPage.validFromLabel}
type="date"
defaultValue={toDateInputValue(code.validFrom)}
/>
<Field
name="validUntil"
label={t.adminVoucherEditPage.validUntilLabel}
type="date"
defaultValue={toDateInputValue(code.validUntil)}
/>
</div>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked={code.isActive} />
{t.adminVoucherEditPage.activeLabel}
</label>
<p className="text-xs text-ink/65">{t.adminVoucherEditPage.redeemedSoFar(code.usedCount, code.maxUses)}</p>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminVoucherEditPage.save}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { BackLink } from "@/components/back-link";
import { createDiscountCodeAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinNewPage(props: PageProps<"/admin/gutscheine/neu">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return (
<div className="max-w-md">
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminVoucherNewPage.typeLabel}</span>
<select name="type" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="PERCENT">{t.adminVoucherNewPage.typePercent}</option>
<option value="FIXED">{t.adminVoucherNewPage.typeFixed}</option>
</select>
</label>
<Field name="value" label={t.adminVoucherNewPage.valueLabel} type="number" placeholder="10" required />
</div>
<Field name="minOrderCents" label={t.adminVoucherNewPage.minOrderLabel} type="number" placeholder="0" />
<Field name="minQuantity" label={t.adminVoucherNewPage.minQuantityLabel} type="number" placeholder="3" />
<Field name="maxUses" label={t.adminVoucherNewPage.maxUsesLabel} type="number" />
<div className="grid grid-cols-2 gap-4">
<Field name="validFrom" label={t.adminVoucherNewPage.validFromLabel} type="date" />
<Field name="validUntil" label={t.adminVoucherNewPage.validUntilLabel} type="date" />
</div>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked />
{t.adminVoucherNewPage.activeLabel}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminVoucherNewPage.create}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
placeholder={placeholder}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+91
View File
@@ -0,0 +1,91 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatPrice, formatDate } from "@/lib/format";
import { deleteDiscountCodeAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
return (
<div>
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl text-forest">{t.adminVouchersPage.title}</h1>
<Link href="/admin/gutscheine/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminVouchersPage.newVoucher}
</Link>
</div>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminVouchersPage.colCode}</th>
<th className="p-4">{t.adminVouchersPage.colDiscount}</th>
<th className="p-4">{t.adminVouchersPage.colMinOrder}</th>
<th className="p-4">{t.adminVouchersPage.colMinQuantity}</th>
<th className="p-4">{t.adminVouchersPage.colUsage}</th>
<th className="p-4">{t.adminVouchersPage.colValidity}</th>
<th className="p-4">{t.adminVouchersPage.colStatus}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{codes.map((code) => (
<tr key={code.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest font-mono">{code.code}</td>
<td className="p-4">
{code.type === "PERCENT" ? `${code.value} %` : formatPrice(code.value)}
</td>
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
<td className="p-4">
{code.usedCount}
{code.maxUses !== null ? ` / ${code.maxUses}` : ""}
</td>
<td className="p-4 whitespace-nowrap">
{code.validFrom ? formatDate(code.validFrom) : t.adminVouchersPage.immediately} –{" "}
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
</td>
<td className="p-4">
{code.isActive ? (
<span className="text-forest">{t.adminVouchersPage.statusActive}</span>
) : (
<span className="text-ink/65">{t.adminVouchersPage.statusInactive}</span>
)}
</td>
<td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap">
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline">
{t.adminVouchersPage.edit}
</Link>
<ConfirmForm
action={deleteDiscountCodeAction}
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
>
<input type="hidden" name="id" value={code.id} />
<button type="submit" className="text-red-700 underline">
{t.adminVouchersPage.delete}
</button>
</ConfirmForm>
</div>
</td>
</tr>
))}
</tbody>
</table>
{codes.length === 0 ? <p className="p-6 text-ink/65">{t.adminVouchersPage.noVouchersYet}</p> : null}
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
};
async function loadCurrentValues(): Promise<Map<string, { valueDe: string; valueEn: string | null }>> {
const rows = await prisma.siteContent.findMany();
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
}
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
const locale = await getLocale();
const t = getDictionary(locale);
const { saved } = await props.searchParams;
const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
{ key: "company", heading: t.adminContentPage.groupCompany },
{ key: "home", heading: t.adminContentPage.groupHome },
{ key: "legal", heading: t.adminContentPage.groupLegal },
];
return (
<div>
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-2">{t.adminContentPage.title}</h1>
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
{saved ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
{t.adminContentPage.saved}
</p>
) : null}
{groups.map((group) => {
const fields = CONTENT_REGISTRY.filter((f) => f.group === group.key);
return (
<form
key={group.key}
action={updateSiteContentAction}
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
>
<input type="hidden" name="group" value={group.key} />
<h2 className="text-lg text-forest">{group.heading}</h2>
{fields.map((def) => {
const row = current.get(def.key);
const rows = ROWS_BY_TYPE[def.type];
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
return (
<label key={def.key} className="block text-sm">
<span className="text-ink/70">{label}</span>
{def.type === "textarea" ? (
<textarea
name={def.key}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
/>
) : (
<input
type="text"
name={def.key}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-forest"
/>
)}
</label>
);
}
const isTextarea = def.type !== "text";
return (
<div key={def.key} className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
<p className="text-sm text-ink/70 mb-2">{label}</p>
<div className="grid sm:grid-cols-2 gap-4">
<label className="block text-xs">
<span className="text-ink/50">{t.adminContentPage.deLabel}</span>
{isTextarea ? (
<textarea
name={`de::${def.key}`}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
) : (
<input
type="text"
name={`de::${def.key}`}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
)}
</label>
<label className="block text-xs">
<span className="text-ink/50">{t.adminContentPage.enLabel}</span>
{isTextarea ? (
<textarea
name={`en::${def.key}`}
rows={rows}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
) : (
<input
type="text"
name={`en::${def.key}`}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-forest"
/>
)}
</label>
</div>
</div>
);
})}
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminContentPage.save}
</button>
</form>
);
})}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getCurrentAdmin } from "@/lib/auth";
import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
// Admin-Daten (Bestellungen, Bestand) muessen immer aktuell sein; zudem
// entkoppelt das den Docker-Build-Schritt von einer DB-Verbindung.
export const dynamic = "force-dynamic";
// Einzelne Admin-Unterseiten (bestellungen, produkte, ...) haben bewusst
// keine eigene Metadata — der Bereich ist ueber robots.ts von der Indexierung
// ausgeschlossen. Dieses Template sorgt trotzdem fuer einen sinnvollen
// Browser-Tab-Titel statt des generischen Startseiten-Titels ueberall im
// Admin-Bereich, ohne jede Unterseite einzeln pflegen zu muessen.
export async function generateMetadata(): Promise<Metadata> {
const t = getDictionary(await getLocale());
return {
title: { default: t.account.navAdmin.title, template: `%s | ${t.account.navAdmin.title}` },
robots: { index: false, follow: false },
};
}
export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// Der Proxy (src/proxy.ts) blockt fremde Requests bereits vor dem Rendern;
// diese zweite, gegen die Datenbank autoritative Pruefung faengt u.a. den
// Fall ab, dass die ADMIN-Rolle eines eingeloggten Kontos nach dem Login
// entzogen wurde (das JWT traegt die Rolle nur als Snapshot vom Login-Zeitpunkt).
const admin = await getCurrentAdmin();
if (!admin) {
redirect("/konto?error=" + encodeURIComponent("Bitte melde dich mit einem Admin-Konto an."));
}
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
// Bestellungen, Rueckerstattungen und Kundendaten — ohne 2FA waere ein
// gestohlenes Passwort allein genug fuer vollen Zugriff. Weiterleitung
// statt Blockade, damit das Konto die Einrichtung direkt nachholen kann.
if (!admin.twoFactorEnabled) {
redirect(
"/konto/zwei-faktor?error=" +
encodeURIComponent("Für Admin-Konten ist Zwei-Faktor-Authentifizierung erforderlich. Bitte richte sie jetzt ein.")
);
}
return (
<AdminShell
adminName={`${admin.firstName} ${admin.lastName}`}
logoutAction={logoutAction}
>
{children}
</AdminShell>
);
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Generisches Suspense-Skeleton fuer den gesamten Admin-Bereich (alle
* Unterseiten sind "force-dynamic" und laden bei jeder Navigation neu
* gegen die DB) — orientiert an der haeufigsten Admin-Ansicht (Tabelle mit
* Filterzeile), statt pro Unterseite ein eigenes Skeleton zu pflegen.
*/
export default function AdminLoading() {
return (
<div className="animate-pulse">
<div className="h-4 w-32 bg-ink/10 rounded-sm" />
<div className="mt-6 h-8 w-56 bg-ink/10 rounded-sm" />
<div className="mt-8 flex flex-wrap gap-3">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-9 w-32 border border-ink/10 bg-ink/5" />
))}
</div>
<div className="mt-8 border border-ink/10">
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className="p-4 border-b border-ink/10 last:border-b-0 flex gap-6">
<div className="h-4 w-24 bg-ink/10 rounded-sm" />
<div className="h-4 w-40 bg-ink/10 rounded-sm" />
<div className="h-4 w-20 bg-ink/10 rounded-sm" />
<div className="h-4 w-16 bg-ink/10 rounded-sm" />
</div>
))}
</div>
</div>
);
}
+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>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const rawProduct = await prisma.product.findUnique({
where: { id },
include: { harvest: true, batch: true, inventory: true },
});
if (!rawProduct) notFound();
const product = { ...rawProduct, tasteTags: asStringArray(rawProduct.tasteTags) };
return (
<div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductEditPage.backToProducts} />
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1>
<p className="text-sm text-ink/65 mb-8">
{t.common.categories[product.category]}
{product.harvest && product.batch
? t.adminProductEditPage.harvestBatchSuffix(
product.harvest.type,
product.harvest.year,
product.batch.code
)
: null}
</p>
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={product.id} />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span>
<textarea
name="description"
defaultValue={product.description}
required
rows={3}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<Field
name="tasteTags"
label={t.adminProductEditPage.tasteTagsLabel}
defaultValue={product.tasteTags.join(", ")}
/>
{product.category === "HONIG" ? (
<>
<Field
name="consistency"
label={t.adminProductEditPage.consistencyLabel}
defaultValue={product.consistency}
/>
<div className="grid grid-cols-3 gap-4">
<Field
name="intensityMild"
label={t.adminProductEditPage.intensityMildLabel}
type="number"
defaultValue={String(product.intensityMild)}
/>
<Field
name="intensityFloral"
label={t.adminProductEditPage.intensityFloralLabel}
type="number"
defaultValue={String(product.intensityFloral)}
/>
<Field
name="intensityKraeftig"
label={t.adminProductEditPage.intensityKraeftigLabel}
type="number"
defaultValue={String(product.intensityKraeftig)}
/>
</div>
</>
) : null}
<div className="grid grid-cols-2 gap-4">
<Field
name="weightGrams"
label={t.adminProductEditPage.weightLabel}
type="number"
defaultValue={String(product.weightGrams)}
required
/>
<Field
name="quantity"
label={t.adminProductEditPage.stockLabel}
type="number"
defaultValue={String(product.inventory?.quantity ?? 0)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
name="priceCents"
label={t.adminProductEditPage.priceLabel}
type="number"
defaultValue={String(product.priceCents)}
required
/>
<Field
name="pricePerKgCents"
label={t.adminProductEditPage.pricePerKgLabel}
type="number"
defaultValue={String(product.pricePerKgCents)}
required
/>
</div>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked={product.isActive} />
{t.adminProductEditPage.visibleInShop}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductEditPage.save}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() {
const t = getDictionary(await getLocale());
const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
]);
return (
<div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductNewPage.categoryLabel}</span>
<select name="category" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="HONIG">{t.common.categories.HONIG}</option>
<option value="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
</select>
</label>
<p className="text-xs text-ink/65">{t.adminProductNewPage.categoryHint}</p>
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductNewPage.harvestLabel}</span>
<select name="harvestId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
{harvests.map((h) => (
<option key={h.id} value={h.id}>
{h.type} {h.year}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductNewPage.batchLabel}</span>
<select name="batchId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{t.adminProductNewPage.batchOption(b.code, b.harvest.type, b.harvest.year)}
</option>
))}
</select>
</label>
</div>
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductNewPage.descriptionLabel}</span>
<textarea name="description" required rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
<Field
name="tasteTags"
label={t.adminProductNewPage.tasteTagsLabel}
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
/>
<Field
name="consistency"
label={t.adminProductNewPage.consistencyLabel}
placeholder={t.adminProductNewPage.consistencyPlaceholder}
/>
<div className="grid grid-cols-3 gap-4">
<Field
name="intensityMild"
label={t.adminProductNewPage.intensityMildLabel}
type="number"
defaultValue="3"
/>
<Field
name="intensityFloral"
label={t.adminProductNewPage.intensityFloralLabel}
type="number"
defaultValue="3"
/>
<Field
name="intensityKraeftig"
label={t.adminProductNewPage.intensityKraeftigLabel}
type="number"
defaultValue="3"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field name="weightGrams" label={t.adminProductNewPage.weightLabel} type="number" required defaultValue="500" />
<Field name="quantity" label={t.adminProductNewPage.stockLabel} type="number" required />
</div>
<div className="grid grid-cols-2 gap-4">
<Field
name="priceCents"
label={t.adminProductNewPage.priceLabel}
type="number"
required
placeholder={t.adminProductNewPage.pricePlaceholder}
/>
<Field
name="pricePerKgCents"
label={t.adminProductNewPage.pricePerKgLabel}
type="number"
required
placeholder={t.adminProductNewPage.pricePerKgPlaceholder}
/>
</div>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked />
{t.adminProductNewPage.visibleInShop}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductNewPage.create}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+69
View File
@@ -0,0 +1,69 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductsPage() {
const t = getDictionary(await getLocale());
const products = await prisma.product.findMany({
include: { harvest: true, batch: true, inventory: true },
orderBy: { createdAt: "desc" },
});
return (
<div>
<BackLink href="/admin" label={t.adminProductsPage.backToDashboard} />
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl text-forest">{t.adminProductsPage.title}</h1>
<Link href="/admin/produkte/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminProductsPage.newProduct}
</Link>
</div>
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminProductsPage.colProduct}</th>
<th className="p-4">{t.adminProductsPage.colCategory}</th>
<th className="p-4">{t.adminProductsPage.colHarvest}</th>
<th className="p-4">{t.adminProductsPage.colBatch}</th>
<th className="p-4">{t.adminProductsPage.colWeight}</th>
<th className="p-4">{t.adminProductsPage.colPrice}</th>
<th className="p-4">{t.adminProductsPage.colStock}</th>
<th className="p-4">{t.adminProductsPage.colStatus}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{products.map((product) => (
<tr key={product.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{product.name}</td>
<td className="p-4">{t.common.categories[product.category]}</td>
<td className="p-4">
{product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"}
</td>
<td className="p-4">{product.batch?.code ?? "—"}</td>
<td className="p-4">{product.weightGrams} g</td>
<td className="p-4">{formatPrice(product.priceCents, product.currency)}</td>
<td className="p-4">{product.inventory?.quantity ?? 0}</td>
<td className="p-4">
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive}
</td>
<td className="p-4">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline">
{t.adminProductsPage.edit}
</Link>
</td>
</tr>
))}
</tbody>
</table>
{products.length === 0 ? (
<p className="p-6 text-ink/65">{t.adminProductsPage.noProductsYet}</p>
) : null}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/app/admin/actions";
import { getStripeClient } from "@/lib/stripe";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@/lib/email-templates";
import { restockOrderItems } from "@/lib/inventory";
import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string {
return String(formData.get(key) ?? "").trim();
}
function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export async function updateReturnRequestStatusAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const status = str(formData, "status");
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return;
const returnRequest = await prisma.returnRequest.findUnique({
where: { id },
select: { status: true },
});
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return;
const updated = await prisma.returnRequest.update({
where: { id },
data: { status: status as "ANGENOMMEN" | "ABGELEHNT" },
include: { order: { select: { orderNumber: true, email: true, firstName: true, locale: true } } },
});
await logAdminAction(admin, {
action: "return_request.status_change",
entityType: "ReturnRequest",
entityId: id,
detail: `${updated.order.orderNumber}: ${status}`,
});
const locale = isLocale(updated.order.locale) ? updated.order.locale : defaultLocale;
const { subject, html, text } =
status === "ANGENOMMEN"
? await returnAcceptedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName })
: await returnRejectedEmail({ locale, orderNumber: updated.order.orderNumber, firstName: updated.order.firstName });
await sendMail({ to: updated.order.email, subject, html, text });
revalidatePath("/admin/retouren");
}
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
const id = str(formData, "id");
const returnRequest = await prisma.returnRequest.findUnique({
where: { id },
include: {
order: {
include: { items: true, payment: { select: { stripePaymentIntentId: true } } },
},
},
});
// Nur eine angenommene Retoure kann erstattet werden — verhindert
// Doppel-Erstattung, falls dieselbe Aktion mehrfach ausgeloest wird.
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
if (!paymentIntentId) {
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
}
const stripe = getStripeClient();
await stripe.refunds.create({ payment_intent: paymentIntentId });
await prisma.$transaction(async (tx) => {
await tx.returnRequest.update({ where: { id }, data: { status: "ERSTATTET" } });
await tx.payment.update({ where: { orderId: returnRequest.orderId }, data: { status: "REFUNDED" } });
// Zurueckgesendete Ware wieder als verfuegbaren Bestand fuehren, analog
// zur Stornierung im Checkout-Webhook.
await restockOrderItems(tx, returnRequest.order.items);
});
await logAdminAction(admin, {
action: "return_request.refund",
entityType: "ReturnRequest",
entityId: id,
detail: returnRequest.order.orderNumber,
});
const { subject, html, text } = await returnRefundedEmail({
locale: isLocale(returnRequest.order.locale) ? returnRequest.order.locale : defaultLocale,
orderNumber: returnRequest.order.orderNumber,
firstName: returnRequest.order.firstName,
});
await sendMail({ to: returnRequest.order.email, subject, html, text });
revalidatePath("/admin/retouren");
}
+128
View File
@@ -0,0 +1,128 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatDate, formatPrice } from "@/lib/format";
import { insensitiveContains } from "@/lib/prisma-search";
import { updateReturnRequestStatusAction, refundReturnRequestAction } from "./actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
const t = getDictionary(await getLocale());
const statusLabels: Record<string, string> = {
ANGEFRAGT: t.adminReturnsPage.statusRequested,
ANGENOMMEN: t.adminReturnsPage.statusAccepted,
ABGELEHNT: t.adminReturnsPage.statusRejected,
ERSTATTET: t.adminReturnsPage.statusRefunded,
};
const { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const returnRequests = await prisma.returnRequest.findMany({
where: query
? {
order: {
OR: [
{ orderNumber: insensitiveContains(query) },
{ firstName: insensitiveContains(query) },
{ lastName: insensitiveContains(query) },
{ email: insensitiveContains(query) },
],
},
}
: undefined,
orderBy: { createdAt: "desc" },
take: 100,
include: {
order: { include: { items: true } },
},
});
return (
<div>
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1>
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6">
<label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminReturnsPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64"
/>
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminReturnsPage.filterButton}
</button>
{query ? (
<Link href="/admin/retouren" className="text-sm text-ink/65 underline">
{t.adminReturnsPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4">
{returnRequests.map((request) => (
<div key={request.id} className="bg-background border border-ink/10 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<p className="text-forest">{request.order.orderNumber}</p>
<p className="text-sm text-ink/70">
{request.order.firstName} {request.order.lastName} · {request.order.email}
</p>
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p>
</div>
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p>
</div>
<ul className="mt-4 text-sm text-ink/70 space-y-1">
{request.order.items.map((item) => (
<li key={item.id}>
{item.quantity} × {item.productName}
</li>
))}
</ul>
<p className="mt-2 text-forest">{formatPrice(request.order.totalCents, request.order.currency)}</p>
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p>
{request.status === "ANGEFRAGT" ? (
<div className="mt-4 flex gap-4">
<form action={updateReturnRequestStatusAction}>
<input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ANGENOMMEN" />
<button type="submit" className="text-xs text-forest underline">
{t.adminReturnsPage.accept}
</button>
</form>
<form action={updateReturnRequestStatusAction}>
<input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ABGELEHNT" />
<button type="submit" className="text-xs text-red-700 underline">
{t.adminReturnsPage.reject}
</button>
</form>
</div>
) : null}
{request.status === "ANGENOMMEN" ? (
<form action={refundReturnRequestAction} className="mt-4">
<input type="hidden" name="id" value={request.id} />
<button type="submit" className="text-xs text-forest underline">
{t.adminReturnsPage.refundNow}
</button>
</form>
) : null}
</div>
))}
{returnRequests.length === 0 ? (
<p className="text-ink/65">
{query ? t.adminReturnsPage.noResultsFiltered : t.adminReturnsPage.noRequestsYet}
</p>
) : null}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { updateShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
const t = getDictionary(await getLocale());
const { id } = await props.params;
const { error } = await props.searchParams;
const tier = await prisma.shippingTier.findUnique({ where: { id } });
if (!tier) notFound();
return (
<div className="max-w-md">
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={tier.id} />
<div className="grid grid-cols-2 gap-4">
<Field
name="minQuantity"
label={t.adminShippingEditPage.minQuantityLabel}
type="number"
defaultValue={String(tier.minQuantity)}
required
/>
<Field
name="maxQuantity"
label={t.adminShippingEditPage.maxQuantityLabel}
type="number"
defaultValue={tier.maxQuantity !== null ? String(tier.maxQuantity) : ""}
/>
</div>
<Field
name="priceCents"
label={t.adminShippingEditPage.priceLabel}
type="number"
defaultValue={String(tier.priceCents)}
required
/>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminShippingEditPage.save}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { BackLink } from "@/components/back-link";
import { createShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandNewPage(props: PageProps<"/admin/versand/neu">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return (
<div className="max-w-md">
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<div className="grid grid-cols-2 gap-4">
<Field name="minQuantity" label={t.adminShippingNewPage.minQuantityLabel} type="number" required placeholder="0" />
<Field name="maxQuantity" label={t.adminShippingNewPage.maxQuantityLabel} type="number" placeholder="3" />
</div>
<Field name="priceCents" label={t.adminShippingNewPage.priceLabel} type="number" required placeholder="700" />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminShippingNewPage.create}
</button>
</form>
</div>
);
}
function Field({
name,
label,
type = "text",
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
placeholder={placeholder}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatPrice } from "@/lib/format";
import { deleteShippingTierAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
return (
<div>
<BackLink href="/admin" label={t.adminShippingPage.backToDashboard} />
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl text-forest">{t.adminShippingPage.title}</h1>
<Link href="/admin/versand/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminShippingPage.newTier}
</Link>
</div>
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminShippingPage.colQuantity}</th>
<th className="p-4">{t.adminShippingPage.colPrice}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{tiers.map((tier) => (
<tr key={tier.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">
{tier.maxQuantity === null
? t.adminShippingPage.quantityFrom(tier.minQuantity)
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
</td>
<td className="p-4">{formatPrice(tier.priceCents)}</td>
<td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap">
<Link href={`/admin/versand/${tier.id}`} className="text-forest underline">
{t.adminShippingPage.edit}
</Link>
<ConfirmForm
action={deleteShippingTierAction}
confirmMessage={t.adminShippingPage.deleteConfirm}
>
<input type="hidden" name="id" value={tier.id} />
<button type="submit" className="text-red-700 underline">
{t.adminShippingPage.delete}
</button>
</ConfirmForm>
</div>
</td>
</tr>
))}
</tbody>
</table>
{tiers.length === 0 ? <p className="p-6 text-ink/65">{t.adminShippingPage.noTiersYet}</p> : null}
</div>
</div>
);
}