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:
1 parent
fb9c2353c6
commit
839bbff595
71 files changed
+762
-416
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user