feat: sichtbare Rueckmeldung fuer alle Aktionen (Toast statt URL-Meldungen)

Viele Aktionen gaben keine Rueckmeldung (Retoure annehmen, Archiv,
Bewertungen, Adressen, Newsletter, Kundensperre ...) oder meldeten Erfolg,
obwohl die zugehoerige E-Mail nie rausging.

- lib/flash: Meldung als kurzlebiges Cookie; Toast im Root-Layout
  (FlashToast). Ersetzt ?message=/?error= in der URL — darueber liess sich
  bisher beliebiger Text als Systemmeldung im Wabenhain-Layout anzeigen
  (Phishing-Hebel). Server Components leiten ueber /api/hinweis mit festen
  Codes und festen Zielen weiter.
- fail()/succeed() zentral und asynchron; ein Quelltext-Test stellt sicher,
  dass jeder Aufruf mit await/return erfolgt (sonst liefe eine Action nach
  einem Fehler weiter).
- sendMail meldet, ob die Mail angenommen wurde; Aktionen mit Mail zeigen
  ehrlich an, wenn der Versand scheitert (z.B. "Link erneut senden").
- Jede Server Action meldet jetzt Erfolg oder den Grund des Abbruchs.
- SubmitFeedback: geklickter Knopf zeigt sofort einen Ladezustand und ist
  bis zur Antwort gegen Doppelklick gesperrt.
- 2FA: nach dem Einrichten zurueck ins Profil statt auf die Seite mit
  "Deaktivieren".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 04:36:36 +02:00
1 parent fb9c2353c6
commit 839bbff595
71 files changed
+762 -416

No files matched your search

+1 -4
View File
@@ -1,4 +1,3 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { getCurrentAdmin } from "@/lib/auth";
import type { CustomerRole } from "@/generated/prisma/client";
@@ -27,9 +26,7 @@ export async function requireAdmin(): Promise<{ id: string; email: string; role:
return admin;
}
export function fail(path: string, message: string): never {
redirect(`${path}?error=${encodeURIComponent(message)}`);
}
export { fail, succeed } from "@/lib/flash";
/** Verhindert, dass der letzte Admin-Account herabgestuft/gesperrt/geloescht wird und das Team sich aussperrt. */
export async function isLastAdmin(customerId: string): Promise<boolean> {
+5 -3
View File
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import type { ArchiveKind } from "@/lib/archive";
import { requireAdmin, str } from "./_shared";
import { requireAdmin, str, fail } from "./_shared";
import { setFlash } from "@/lib/flash";
const LIST_PATH: Record<ArchiveKind, string> = {
orders: "/admin/bestellungen",
@@ -35,7 +36,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
const kind = str(formData, "kind");
const id = str(formData, "id");
const archive = str(formData, "mode") === "archive";
if (!isArchiveKind(kind) || !id) return;
if (!isArchiveKind(kind) || !id) return fail("/admin", "Ungültige Anfrage.");
const data = archive
? { archivedAt: new Date(), archivedManually: true }
@@ -51,7 +52,7 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
: kind === "returns"
? await prisma.returnRequest.updateMany({ where, data })
: await prisma.product.updateMany({ where, data });
if (result.count === 0) return;
if (result.count === 0) return fail(LIST_PATH[kind], "Eintrag nicht gefunden.");
await logAdminAction(admin, {
action: archive ? "archive.manual_archive" : "archive.manual_restore",
@@ -60,4 +61,5 @@ export async function setArchivedAction(formData: FormData): Promise<void> {
});
revalidatePath(LIST_PATH[kind]);
await setFlash("success", archive ? "Ins Archiv verschoben." : "Aus dem Archiv wiederhergestellt.");
}
+2 -2
View File
@@ -1,12 +1,12 @@
"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 { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared";
import { succeed } from "@/lib/flash";
/**
* Generisch fuer alle drei Inhalts-Gruppen (Firmendaten/Homepage/Rechtstexte)
@@ -70,5 +70,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
});
revalidatePath("/", "layout");
redirect("/admin/inhalte?saved=1");
return succeed("/admin/inhalte", "Inhalte gespeichert.");
}
+28 -19
View File
@@ -1,6 +1,5 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/prisma";
@@ -10,6 +9,7 @@ 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";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
export async function createCustomerAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -21,12 +21,12 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
role: str(formData, "role") || "KUNDE",
});
if (!parsed.success) {
fail("/admin/benutzer/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return 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.");
return fail("/admin/benutzer/neu", "Für diese E-Mail-Adresse besteht bereits ein Konto.");
}
// Kein vom Admin gesetztes Passwort: das Konto erhaelt einen zufaelligen,
@@ -43,7 +43,7 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
},
});
await createAndSendPasswordResetEmail(customer);
const mailSent = await createAndSendPasswordResetEmail(customer);
await logAdminAction(admin, {
action: "customer.create",
entityType: "Customer",
@@ -52,7 +52,13 @@ export async function createCustomerAction(formData: FormData): Promise<void> {
});
revalidatePath("/admin/benutzer");
redirect("/admin/benutzer");
if (!mailSent) {
return fail(
"/admin/benutzer",
`Konto ${customer.email} wurde angelegt, aber die Einladung zum Passwort-Setzen ging nicht raus. ${MAIL_FAILED_HINT} Danach auf der Benutzerseite erneut senden.`
);
}
return succeed("/admin/benutzer", `Konto ${customer.email} wurde angelegt — die Einladung zum Passwort-Setzen ist verschickt.`);
}
export async function updateCustomerAction(formData: FormData): Promise<void> {
@@ -67,23 +73,23 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
role: str(formData, "role") || "KUNDE",
});
if (!parsed.success) {
fail(`/admin/benutzer/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return 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.");
return 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) return 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.");
return 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.");
return fail(`/admin/benutzer/${id}`, "Für diese E-Mail-Adresse besteht bereits ein anderes Konto.");
}
const emailChanged = parsed.data.email !== target.email;
@@ -116,7 +122,7 @@ export async function updateCustomerAction(formData: FormData): Promise<void> {
});
revalidatePath("/admin/benutzer");
redirect("/admin/benutzer");
return succeed("/admin/benutzer", "Änderungen gespeichert.");
}
export async function blockCustomerAction(formData: FormData): Promise<void> {
@@ -124,10 +130,10 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
const id = str(formData, "id");
if (id === admin.id) {
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht sperren.");
return 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.");
return fail("/admin/benutzer", "Der letzte Admin-Account kann nicht gesperrt werden.");
}
// sessionVersion hochzaehlen, damit eine bereits ausgestellte Session der
@@ -139,6 +145,7 @@ export async function blockCustomerAction(formData: FormData): Promise<void> {
});
await logAdminAction(admin, { action: "customer.block", entityType: "Customer", entityId: id, detail: blocked.email });
revalidatePath("/admin/benutzer");
await setFlash("success", `${blocked.email} wurde gesperrt und überall abgemeldet.`);
}
export async function unblockCustomerAction(formData: FormData): Promise<void> {
@@ -156,6 +163,7 @@ export async function unblockCustomerAction(formData: FormData): Promise<void> {
detail: unblocked.email,
});
revalidatePath("/admin/benutzer");
await setFlash("success", `${unblocked.email} wurde entsperrt.`);
}
export async function deleteCustomerAction(formData: FormData): Promise<void> {
@@ -163,16 +171,16 @@ export async function deleteCustomerAction(formData: FormData): Promise<void> {
const id = str(formData, "id");
if (id === admin.id) {
fail("/admin/benutzer", "Du kannst dein eigenes Konto nicht löschen.");
return 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.");
return 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");
return succeed("/admin/benutzer", `Konto ${deleted.email} wurde gelöscht.`);
}
/** Loest anstelle eines vom Admin direkt gesetzten Passworts einen regulaeren Reset-Link per E-Mail aus. */
@@ -181,14 +189,15 @@ export async function sendCustomerPasswordResetAction(formData: FormData): Promi
const id = str(formData, "id");
const customer = await prisma.customer.findUnique({ where: { id } });
if (!customer) fail("/admin/benutzer", "Konto nicht gefunden.");
if (!customer) return fail("/admin/benutzer", "Konto nicht gefunden.");
await createAndSendPasswordResetEmail(customer);
const mailSent = 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."));
if (!mailSent) return fail(`/admin/benutzer/${id}`, MAIL_FAILED_HINT);
return succeed(`/admin/benutzer/${id}`, "Passwort-Reset-E-Mail wurde verschickt.");
}
+11 -9
View File
@@ -6,6 +6,7 @@ import { logAdminAction } from "@/lib/audit-log";
import { getCompanyInfo } from "@/lib/company-info";
import { createDhlShipmentLabel, DhlApiError, DhlNotConfiguredError, type DhlCreateShipmentResult } from "@/lib/dhl";
import { requireAdmin, fail, str } from "./_shared";
import { setFlash } from "@/lib/flash";
// Platzhalterwert, waehrend eine Versandmarken-Anfrage bei DHL laeuft — siehe
// Kommentar bei der Serializable-Transaktion unten. Kein echtes DHL-Format,
@@ -34,17 +35,17 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
},
});
if (!order) {
fail("/admin/bestellungen", "Bestellung nicht gefunden.");
return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
}
if (order.fulfillmentMethod !== "VERSAND") {
fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
return fail(detailPath, "Diese Bestellung ist zur Abholung markiert, kein Versand per DHL nötig.");
}
if (order.payment?.status !== "SUCCEEDED") {
fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
return fail(detailPath, "Die Bestellung ist noch nicht bezahlt — es kann keine Versandmarke erstellt werden.");
}
if (order.shipment?.trackingNumber) {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst.");
}
// Atomarer Claim VOR dem (kostenpflichtigen) DHL-Aufruf: der obige
@@ -76,7 +77,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
claimed = false;
}
if (!claimed) {
fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
return fail(detailPath, "Für diese Bestellung wurde bereits eine DHL-Sendungsnummer erfasst oder eine Anfrage läuft gerade.");
}
const totalWeightGrams = order.items.reduce((sum, item) => sum + item.weightGrams * item.quantity, 0);
@@ -126,13 +127,13 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
.catch(() => {});
if (error instanceof DhlNotConfiguredError) {
fail(detailPath, error.message);
return fail(detailPath, error.message);
}
if (error instanceof DhlApiError) {
fail(detailPath, error.message);
return fail(detailPath, error.message);
}
const message = error instanceof Error ? error.message : "Unbekannter Fehler";
fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
return fail(detailPath, `DHL-Versandmarke konnte nicht erstellt werden: ${message}`);
}
try {
@@ -153,7 +154,7 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
`createDhlLabelAction: DHL-Sendungsnummer ${result.trackingNumber} fuer Bestellung ${order.orderNumber} (${orderId}) konnte nicht gespeichert werden — manuelle Nacherfassung noetig.`,
error
);
fail(
return fail(
detailPath,
"Die DHL-Versandmarke wurde erstellt, aber die Sendungsnummer konnte nicht gespeichert werden. Bitte Support kontaktieren, um sie manuell nachzutragen."
);
@@ -168,4 +169,5 @@ export async function createDhlLabelAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bestellungen");
revalidatePath(detailPath);
await setFlash("success", `DHL-Versandmarke erstellt. Sendungsnummer: ${result.trackingNumber}`);
}
+9 -9
View File
@@ -1,11 +1,11 @@
"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";
import { succeed } from "@/lib/flash";
function parseDiscountCodeForm(formData: FormData) {
const rawMax = str(formData, "maxUses");
@@ -30,12 +30,12 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) {
fail("/admin/gutscheine/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return 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.");
return fail("/admin/gutscheine/neu", "Dieser Code existiert bereits.");
}
const code = await prisma.discountCode.create({ data: parsed.data });
@@ -48,7 +48,7 @@ export async function createDiscountCodeAction(formData: FormData): Promise<void
});
revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine");
return succeed("/admin/gutscheine", "Gutschein angelegt.");
}
export async function updateDiscountCodeAction(formData: FormData): Promise<void> {
@@ -57,12 +57,12 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
const parsed = parseDiscountCodeForm(formData);
if (!parsed.success) {
fail(`/admin/gutscheine/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return 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.");
return fail(`/admin/gutscheine/${id}`, "Dieser Code wird bereits von einem anderen Gutschein verwendet.");
}
await prisma.discountCode.update({ where: { id }, data: parsed.data });
@@ -75,7 +75,7 @@ export async function updateDiscountCodeAction(formData: FormData): Promise<void
});
revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine");
return succeed("/admin/gutscheine", "Gutschein gespeichert.");
}
export async function deleteDiscountCodeAction(formData: FormData): Promise<void> {
@@ -89,7 +89,7 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
try {
deleted = await prisma.discountCode.delete({ where: { id } });
} catch {
fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
return fail("/admin/gutscheine", "Der Gutschein wurde nicht gefunden — möglicherweise bereits gelöscht.");
}
await logAdminAction(admin, {
@@ -100,5 +100,5 @@ export async function deleteDiscountCodeAction(formData: FormData): Promise<void
});
revalidatePath("/admin/gutscheine");
redirect("/admin/gutscheine");
return succeed("/admin/gutscheine", "Gutschein gelöscht.");
}
+13 -13
View File
@@ -1,11 +1,11 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, num, strArraySlots } from "./_shared";
import { succeed } from "@/lib/flash";
/**
* Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel
@@ -55,7 +55,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten");
return succeed("/admin/ernten", "Ernte angelegt.");
}
export async function updateHarvestAction(formData: FormData): Promise<void> {
@@ -80,7 +80,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten");
return succeed("/admin/ernten", "Ernte gespeichert.");
}
/**
@@ -99,10 +99,10 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
where: { id },
include: { _count: { select: { batches: true, products: true } } },
});
if (!harvest) fail("/admin/ernten", "Ernte nicht gefunden.");
if (!harvest) return fail("/admin/ernten", "Ernte nicht gefunden.");
if (harvest._count.batches > 0 || harvest._count.products > 0) {
fail(
return fail(
"/admin/ernten",
`Diese Ernte hat noch ${harvest._count.batches} Charge(n) und ${harvest._count.products} Produkt(e). Lösche oder verschiebe diese zuerst.`
);
@@ -119,7 +119,7 @@ export async function deleteHarvestAction(formData: FormData): Promise<void> {
revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten");
return succeed("/admin/ernten", "Ernte gelöscht.");
}
/**
@@ -148,7 +148,7 @@ export async function toggleHarvestPublishedAction(formData: FormData): Promise<
revalidatePath("/admin/ernten");
revalidatePath("/ernten");
redirect("/admin/ernten");
return succeed("/admin/ernten", "Veröffentlichungsstatus geändert.");
}
export async function createBatchAction(formData: FormData): Promise<void> {
@@ -169,7 +169,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
revalidatePath("/admin/chargen");
redirect("/admin/chargen");
return succeed("/admin/chargen", "Charge angelegt.");
}
export async function updateBatchAction(formData: FormData): Promise<void> {
@@ -190,7 +190,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
revalidatePath("/admin/chargen");
redirect("/admin/chargen");
return succeed("/admin/chargen", "Charge gespeichert.");
}
/**
@@ -210,16 +210,16 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
where: { id },
include: { _count: { select: { products: true, orderItems: true } } },
});
if (!batch) fail("/admin/chargen", "Charge nicht gefunden.");
if (!batch) return fail("/admin/chargen", "Charge nicht gefunden.");
if (batch._count.orderItems > 0) {
fail(
return fail(
"/admin/chargen",
`Charge "${batch.code}" wurde bereits in ${batch._count.orderItems} Bestellposition(en) verwendet und kann deshalb nicht gelöscht werden.`
);
}
if (batch._count.products > 0) {
fail(
return fail(
"/admin/chargen",
`Charge "${batch.code}" ist noch ${batch._count.products} Produkt(en) zugeordnet. Löse diese Zuordnung zuerst.`
);
@@ -230,5 +230,5 @@ export async function deleteBatchAction(formData: FormData): Promise<void> {
await logAdminAction(admin, { action: "batch.delete", entityType: "Batch", entityId: id, detail: batch.code });
revalidatePath("/admin/chargen");
redirect("/admin/chargen");
return succeed("/admin/chargen", "Charge gelöscht.");
}
+4 -3
View File
@@ -3,13 +3,14 @@
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str } from "./_shared";
import { requireAdmin, str, fail } from "./_shared";
import { setFlash } from "@/lib/flash";
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;
if (!["NEU", "IN_BEARBEITUNG", "ABGESCHLOSSEN", "ABGELEHNT"].includes(status)) return fail("/admin/anfragen", "Ungültiger Status.");
await prisma.businessInquiry.update({
where: { id },
@@ -22,6 +23,6 @@ export async function updateBusinessInquiryStatusAction(formData: FormData): Pro
entityId: id,
detail: status,
});
revalidatePath("/admin/anfragen");
await setFlash("success", "Status der Anfrage gespeichert.");
}
+28 -16
View File
@@ -1,7 +1,6 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { sendMail } from "@/lib/mail";
@@ -13,6 +12,7 @@ import { getStripeClient } from "@/lib/stripe";
import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
const ORDER_STATUSES = [
"NEU",
@@ -124,7 +124,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
const status = str(formData, "status") as (typeof ORDER_STATUSES)[number];
const detailPath = `/admin/bestellungen/${orderId}`;
if (!ORDER_STATUSES.includes(status)) {
fail(detailPath, "Ungültiger Status.");
return fail(detailPath, "Ungültiger Status.");
}
const result = await prisma.$transaction(async (tx) => {
@@ -198,9 +198,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
});
if (!result) {
fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
return fail(detailPath, "Die Bestellung wurde nicht gefunden — möglicherweise wurde sie zwischenzeitlich gelöscht.");
}
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
let mailSent: boolean | undefined;
if (result) {
await logAdminAction(admin, {
action: "order.status_change",
@@ -238,7 +241,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Ware ist bereits wieder im Bestand und die Absicht war eindeutig.
// Aber der offene Rest muss auf dem Bildschirm landen und nicht nur
// im Log, sonst haelt die Imkerei den Vorgang fuer abgeschlossen.
fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
return fail(detailPath, `Bestellung storniert, aber: ${stornoWarnings.join(" ")}`);
}
}
@@ -257,7 +260,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
await sendMail({ to: result.email, subject, html, text });
mailSent = await sendMail({ to: result.email, subject, html, text });
}
if (status === "ABHOLBEREIT" && result.status !== "ABHOLBEREIT") {
const { subject, html, text } = await pickupReadyEmail({
@@ -265,11 +268,14 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
orderNumber: result.orderNumber,
firstName: result.firstName,
});
await sendMail({ to: result.email, subject, html, text });
mailSent = await sendMail({ to: result.email, subject, html, text });
}
}
revalidatePath("/admin/bestellungen");
const done = `Status von ${result.orderNumber} gespeichert.`;
if (mailSent === false) return fail(detailPath, `${done} Aber: ${MAIL_FAILED_HINT}`);
await setFlash("success", mailSent ? `${done} Die Kundschaft wurde per E-Mail informiert.` : done);
}
/**
@@ -324,7 +330,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
},
});
if (!order) {
fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
return fail("/admin/bestellungen", "Die Bestellung wurde nicht gefunden — möglicherweise ist sie bereits gelöscht.");
}
const verdict = checkOrderDeletable({
@@ -333,7 +339,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
hasInvoice: order.invoice !== null,
});
if (!verdict.deletable) {
fail(detailPath, {
return fail(detailPath, {
invoice: `Zu dieser Bestellung gehört die Rechnung ${order.invoice?.invoiceNumber}. Rechnungen sind aufbewahrungspflichtig und dürfen nicht gelöscht werden — storniere die Bestellung stattdessen.`,
payment:
"Zu dieser Bestellung ist ein Zahlungsvorgang erfasst. Sie belegt damit einen Geschäftsvorfall und darf nicht gelöscht werden — storniere sie stattdessen.",
@@ -351,7 +357,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
if (session.status === "open") {
await stripe.checkout.sessions.expire(order.stripeCheckoutSessionId);
} else if (session.status === "complete") {
fail(
return fail(
detailPath,
"Diese Bestellung wurde bei Stripe zwischenzeitlich bezahlt und darf nicht gelöscht werden. Bitte den Zahlungseingang prüfen und die Bestellung stornieren."
);
@@ -365,7 +371,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
`[order-delete] Zahlungssitzung ${order.stripeCheckoutSessionId} der Bestellung ${order.orderNumber} konnte nicht geprüft werden:`,
error
);
fail(
return fail(
detailPath,
"Die offene Zahlungssitzung konnte nicht geschlossen werden. Die Bestellung wurde deshalb NICHT gelöscht — bitte später erneut versuchen."
);
@@ -394,7 +400,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
});
if (!deleted) {
fail(
return fail(
detailPath,
"Die Bestellung hat sich zwischenzeitlich geändert (vermutlich ist eine Zahlung eingegangen) und wurde nicht gelöscht. Bitte die Seite neu laden."
);
@@ -410,9 +416,7 @@ export async function deleteOrderAction(formData: FormData): Promise<void> {
});
revalidatePath("/admin/bestellungen");
redirect(
`/admin/bestellungen?message=${encodeURIComponent(`Bestellung ${order.orderNumber} wurde gelöscht.`)}`
);
return succeed("/admin/bestellungen", `Bestellung ${order.orderNumber} wurde gelöscht.`);
}
/**
@@ -433,7 +437,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
where: { id: orderId },
select: { orderNumber: true, email: true, firstName: true, status: true, locale: true },
});
if (!order) return;
if (!order) return fail("/admin/bestellungen", "Bestellung nicht gefunden.");
await prisma.shipment.upsert({
where: { orderId },
@@ -448,6 +452,7 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
detail: `${order.orderNumber}: ${carrier ?? "—"} ${trackingNumber ?? ""}`.trim(),
});
let mailSent: boolean | undefined;
if (order.status === "VERSENDET" && carrier && trackingNumber) {
const { subject, html, text } = await shipmentEmail({
locale: isLocale(order.locale) ? order.locale : defaultLocale,
@@ -457,9 +462,16 @@ export async function updateShipmentTrackingAction(formData: FormData): Promise<
trackingNumber,
trackingUrl: getCarrierTrackingUrl(carrier, trackingNumber),
});
await sendMail({ to: order.email, subject, html, text });
mailSent = await sendMail({ to: order.email, subject, html, text });
}
revalidatePath("/admin/bestellungen");
revalidatePath(`/admin/bestellungen/${orderId}`);
if (mailSent === false) {
return fail(`/admin/bestellungen/${orderId}`, `Sendungsdaten gespeichert. Aber: ${MAIL_FAILED_HINT}`);
}
await setFlash(
"success",
mailSent ? "Sendungsdaten gespeichert und per E-Mail an die Kundschaft geschickt." : "Sendungsdaten gespeichert."
);
}
+13 -13
View File
@@ -1,6 +1,5 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
@@ -8,6 +7,7 @@ import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash";
/**
* Ein Produkt darf nicht aktiv geschaltet werden, solange kein Preis
@@ -18,9 +18,9 @@ import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
* Kombination "aktiv UND Preis 0", denn die waere im Shop als kostenloses
* Produkt bestellbar.
*/
function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): void {
async function assertPricedIfActive(redirectPath: string, isActive: boolean, priceCents: number): Promise<void> {
if (isActive && priceCents <= 0) {
fail(
return fail(
redirectPath,
"Ein Produkt ohne Preis kann nicht aktiv geschaltet werden. Bitte zuerst einen Preis eintragen — oder das Produkt als Entwurf (nicht aktiv) speichern."
);
@@ -42,12 +42,12 @@ export async function createProductAction(formData: FormData): Promise<void> {
vatRatePercent: str(formData, "vatRatePercent"),
});
if (!parsedNumbers.success) {
fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail("/admin/produkte/neu", parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
@@ -64,7 +64,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
// hervorgeht, dass lediglich die Adresse belegt ist.
const slugTaken = await prisma.product.findUnique({ where: { slug }, select: { id: true } });
if (slugTaken) {
fail(
return fail(
"/admin/produkte/neu",
`Die Adresse "${slug}" ist bereits vergeben. Bei gleichnamigen Produkten aus verschiedenen Jahrgaengen im Feld "Slug" eine eigene Adresse eintragen, zum Beispiel "${slug}-${new Date().getFullYear()}".`
);
@@ -101,7 +101,7 @@ export async function createProductAction(formData: FormData): Promise<void> {
});
revalidatePath("/admin/produkte");
redirect("/admin/produkte");
return succeed("/admin/produkte", "Produkt angelegt.");
}
/**
@@ -131,10 +131,10 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
where: { id },
include: { _count: { select: { orderItems: true } } },
});
if (!product) fail("/admin/produkte", "Produkt nicht gefunden.");
if (!product) return fail("/admin/produkte", "Produkt nicht gefunden.");
if (product._count.orderItems > 0) {
fail(
return fail(
"/admin/produkte",
`"${product.name}" wurde bereits in ${product._count.orderItems} Bestellposition(en) verkauft und kann deshalb nicht gelöscht werden — die zugehörigen Rechnungen müssen nachvollziehbar bleiben. Setze das Produkt stattdessen auf "nicht aktiv", dann verschwindet es aus dem Shop.`
);
@@ -150,7 +150,7 @@ export async function deleteProductAction(formData: FormData): Promise<void> {
});
revalidatePath("/admin/produkte");
redirect("/admin/produkte");
return succeed("/admin/produkte", "Produkt gelöscht.");
}
export async function updateProductAction(formData: FormData): Promise<void> {
@@ -168,12 +168,12 @@ export async function updateProductAction(formData: FormData): Promise<void> {
vatRatePercent: str(formData, "vatRatePercent"),
});
if (!parsedNumbers.success) {
fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail(`/admin/produkte/${id}`, parsedNumbers.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const numbers = parsedNumbers.data;
const isActive = formData.get("isActive") === "on";
assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
await assertPricedIfActive(`/admin/produkte/${id}`, isActive, numbers.priceCents);
const previousInventory = await prisma.inventory.findUnique({
where: { productId: id },
@@ -216,5 +216,5 @@ export async function updateProductAction(formData: FormData): Promise<void> {
}
revalidatePath("/admin/produkte");
redirect("/admin/produkte");
return succeed("/admin/produkte", "Produkt gespeichert.");
}
+3
View File
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log";
import { requireAdmin, str } from "./_shared";
import { setFlash } from "@/lib/flash";
export async function approveReviewAction(formData: FormData): Promise<void> {
const admin = await requireAdmin();
@@ -24,6 +25,7 @@ export async function approveReviewAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung freigegeben — sie ist jetzt im Shop sichtbar.");
}
export async function deleteReviewAction(formData: FormData): Promise<void> {
@@ -44,4 +46,5 @@ export async function deleteReviewAction(formData: FormData): Promise<void> {
revalidatePath("/admin/bewertungen");
revalidatePath(`/honig/${review.product.slug}`);
await setFlash("success", "Bewertung gelöscht.");
}
+6 -6
View File
@@ -1,11 +1,11 @@
"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";
import { succeed } from "@/lib/flash";
function parseShippingTierForm(formData: FormData) {
const rawMax = str(formData, "maxQuantity");
@@ -21,7 +21,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
const parsed = parseShippingTierForm(formData);
if (!parsed.success) {
fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail("/admin/versand/neu", parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const tier = await prisma.shippingTier.create({
@@ -42,7 +42,7 @@ export async function createShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand");
revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung");
redirect("/admin/versand");
return succeed("/admin/versand", "Versandstufe angelegt.");
}
export async function updateShippingTierAction(formData: FormData): Promise<void> {
@@ -51,7 +51,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
const parsed = parseShippingTierForm(formData);
if (!parsed.success) {
fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
return fail(`/admin/versand/${id}`, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
await prisma.shippingTier.update({
@@ -68,7 +68,7 @@ export async function updateShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand");
revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung");
redirect("/admin/versand");
return succeed("/admin/versand", "Versandstufe gespeichert.");
}
export async function deleteShippingTierAction(formData: FormData): Promise<void> {
@@ -82,5 +82,5 @@ export async function deleteShippingTierAction(formData: FormData): Promise<void
revalidatePath("/admin/versand");
revalidatePath("/warenkorb");
revalidatePath("/versand-zahlung");
redirect("/admin/versand");
return succeed("/admin/versand", "Versandstufe gelöscht.");
}
-7
View File
@@ -17,7 +17,6 @@ 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(),
@@ -36,12 +35,6 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
</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} />
+1 -5
View File
@@ -3,9 +3,8 @@ 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">) {
export default async function AdminBenutzerNewPage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
return (
<div className="max-w-xl">
@@ -13,9 +12,6 @@ export default async function AdminBenutzerNewPage(props: PageProps<"/admin/benu
<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">
+1 -7
View File
@@ -12,7 +12,7 @@ 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 { q } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const [admin, customers] = await Promise.all([
@@ -42,12 +42,6 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
</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">
-7
View File
@@ -19,7 +19,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { id } = await props.params;
const { error, message } = await props.searchParams;
const order = await prisma.order.findUnique({
where: { id },
include: {
@@ -47,12 +46,6 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<div className="max-w-3xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
{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}
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div>
+1 -7
View File
@@ -15,7 +15,7 @@ import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { q, status, error, message, ansicht } = await props.searchParams;
const { q, status, ansicht } = await props.searchParams;
const query = typeof q === "string" ? q.trim() : "";
const statusFilter = typeof status === "string" && status in statusLabels ? (status as OrderStatus) : undefined;
const isArchive = isArchiveView(ansicht);
@@ -53,12 +53,6 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */}
{message ? (
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
) : null}
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<AdminArchiveTabs
basePath="/admin/bestellungen"
+1 -5
View File
@@ -7,9 +7,8 @@ import { createBatchAction, deleteBatchAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchesPage(props: PageProps<"/admin/chargen">) {
export default async function AdminBatchesPage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [batches, harvests] = await Promise.all([
prisma.batch.findMany({
orderBy: { fillDate: "desc" },
@@ -22,9 +21,6 @@ export default async function AdminBatchesPage(props: PageProps<"/admin/chargen"
<div>
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.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}
<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">
-4
View File
@@ -11,7 +11,6 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale());
const searchParams = await props.searchParams;
const error = searchParams.error;
const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } },
@@ -22,9 +21,6 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.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}
<ApiCoreSyncResult searchParams={searchParams} />
<div className="overflow-x-auto bg-background border border-ink/10">
-4
View File
@@ -13,7 +13,6 @@ function toDateInputValue(date: Date | null): string {
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();
@@ -22,9 +21,6 @@ export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gu
<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} />
+1 -5
View File
@@ -3,18 +3,14 @@ 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">) {
export default async function AdminGutscheinNewPage() {
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 />
+1 -5
View File
@@ -7,9 +7,8 @@ import { deleteDiscountCodeAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinePage(props: PageProps<"/admin/gutscheine">) {
export default async function AdminGutscheinePage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
return (
@@ -22,9 +21,6 @@ export default async function AdminGutscheinePage(props: PageProps<"/admin/gutsc
</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">
+1 -8
View File
@@ -197,10 +197,9 @@ function GroupForm({
);
}
export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte">) {
export default async function AdminInhaltePage() {
const locale = await getLocale();
const t = getDictionary(locale);
const { saved } = await props.searchParams;
const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
@@ -218,12 +217,6 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
<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}
<section className="mb-12">
<h2 className="text-xl text-forest mb-1">{t.adminContentPage.sectionImages}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
+3 -5
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { noticeUrl } from "@/lib/notice";
import { getCurrentAdmin } from "@/lib/auth";
import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell";
@@ -30,7 +31,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// 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."));
redirect(noticeUrl("admin-login"));
}
// Erzwungen statt nur empfohlen: Admin-Konten haben Zugriff auf
@@ -38,10 +39,7 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
// 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.")
);
redirect(noticeUrl("admin-2fa"));
}
return (
-7
View File
@@ -10,7 +10,6 @@ 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 { error } = await props.searchParams;
const rawProduct = await prisma.product.findUnique({
where: { id },
include: { harvest: true, batch: true, inventory: true },
@@ -36,12 +35,6 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
)
: null}
</p>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem Formular und wusste nicht, was abgelehnt wurde. */}
{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={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 />
+1 -8
View File
@@ -5,9 +5,8 @@ import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage(props: PageProps<"/admin/produkte/neu">) {
export default async function AdminProductNewPage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
@@ -17,12 +16,6 @@ export default async function AdminProductNewPage(props: PageProps<"/admin/produ
<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>
{/* Abgelehnte Eingaben kommen ueber fail() als ?error= zurueck (siehe
admin/actions/_shared.ts). Ohne diese Ausgabe landete man wortlos
wieder auf dem leeren Formular und wusste nicht, was fehlte. */}
{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={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} />
+18 -11
View File
@@ -1,6 +1,6 @@
"use server";
import { redirect } from "next/navigation";
import { fail, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/app/admin/actions";
@@ -15,15 +15,12 @@ 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;
if (!["ANGENOMMEN", "ABGELEHNT"].includes(status)) return fail("/admin/retouren", "Ungültiger Status.");
const returnRequest = await prisma.returnRequest.findUnique({
where: { id },
@@ -31,7 +28,9 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
});
// Nur von "ANGEFRAGT" aus entscheiden — verhindert, dass eine bereits
// erstattete oder abgelehnte Retoure nachtraeglich ueberschrieben wird.
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") return;
if (!returnRequest || returnRequest.status !== "ANGEFRAGT") {
return fail("/admin/retouren", "Diese Retoure wurde bereits entschieden oder existiert nicht mehr.");
}
const updated = await prisma.returnRequest.update({
where: { id },
@@ -51,9 +50,12 @@ export async function updateReturnRequestStatusAction(formData: FormData): Promi
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 });
const mailSent = await sendMail({ to: updated.order.email, subject, html, text });
revalidatePath("/admin/retouren");
const done = `Retoure ${updated.order.orderNumber} ${status === "ANGENOMMEN" ? "angenommen" : "abgelehnt"}.`;
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
}
export async function refundReturnRequestAction(formData: FormData): Promise<void> {
@@ -68,11 +70,13 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
},
},
});
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") return;
if (!returnRequest || returnRequest.status !== "ANGENOMMEN") {
return fail("/admin/retouren", "Diese Retoure kann nicht (mehr) erstattet werden.");
}
const paymentIntentId = returnRequest.order.payment?.stripePaymentIntentId;
if (!paymentIntentId) {
fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
return fail("/admin/retouren", "Keine Stripe-Zahlung zu dieser Bestellung hinterlegt — bitte manuell erstatten.");
}
// Status ATOMAR und VOR dem Stripe-Aufruf auf ERSTATTET setzen (bedingt auf
@@ -87,7 +91,7 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
where: { id, status: "ANGENOMMEN" },
data: { status: "ERSTATTET" },
});
if (claimed.count === 0) return;
if (claimed.count === 0) return fail("/admin/retouren", "Diese Retoure wird bereits erstattet.");
const stripe = getStripeClient();
try {
@@ -116,7 +120,10 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
orderNumber: returnRequest.order.orderNumber,
firstName: returnRequest.order.firstName,
});
await sendMail({ to: returnRequest.order.email, subject, html, text });
const mailSent = await sendMail({ to: returnRequest.order.email, subject, html, text });
revalidatePath("/admin/retouren");
const done = `Retoure ${returnRequest.order.orderNumber} erstattet — der Betrag geht über Stripe zurück.`;
if (!mailSent) return fail("/admin/retouren", `${done} Aber: ${MAIL_FAILED_HINT}`);
return succeed("/admin/retouren", `${done} Die Kundschaft wurde per E-Mail informiert.`);
}
-4
View File
@@ -8,7 +8,6 @@ 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();
@@ -17,9 +16,6 @@ export default async function AdminVersandEditPage(props: PageProps<"/admin/vers
<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} />
+1 -5
View File
@@ -3,18 +3,14 @@ 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">) {
export default async function AdminVersandNewPage() {
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">
+1 -5
View File
@@ -7,9 +7,8 @@ import { deleteShippingTierAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandPage(props: PageProps<"/admin/versand">) {
export default async function AdminVersandPage() {
const t = getDictionary(await getLocale());
const { error } = await props.searchParams;
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
return (
@@ -24,9 +23,6 @@ export default async function AdminVersandPage(props: PageProps<"/admin/versand"
<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">