feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um: - Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und 3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix - Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT), Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt, Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL) - Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR - Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau, Warenkorb mit Schluessel je Position - Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP, Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP - Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen, Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen - Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten, SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung, Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -15,3 +15,8 @@ export * from "./actions/content";
|
||||
export * from "./actions/apicore-sync";
|
||||
export * from "./actions/dhl";
|
||||
export * from "./actions/archive";
|
||||
export * from "./actions/sponsorship";
|
||||
export * from "./actions/sponsorship-offers";
|
||||
export * from "./actions/sponsorship-harvest";
|
||||
export * from "./actions/sponsorship-subscriptions";
|
||||
export * from "./actions/sponsorship-loyalty";
|
||||
@@ -0,0 +1,124 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { isIsoDate } from "@/lib/certificate/schema";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
|
||||
const windowSchema = z
|
||||
.object({
|
||||
startsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Beginndatum angeben."),
|
||||
endsOn: z.string().refine(isIsoDate, "Bitte ein gültiges Enddatum angeben."),
|
||||
plannedJars: z.coerce
|
||||
.number({ error: "Geplante Gläser: bitte eine ganze Zahl." })
|
||||
.int("Geplante Gläser: bitte eine ganze Zahl.")
|
||||
.min(0, "Geplante Gläser: mindestens 0.")
|
||||
.max(100_000, "Geplante Gläser: höchstens 100000."),
|
||||
})
|
||||
.refine((value) => value.endsOn >= value.startsOn, "Das Ende darf nicht vor dem Beginn liegen.");
|
||||
|
||||
function windowPath(offerId: string): string {
|
||||
return `/admin/patenschaften/angebote/${offerId}/erntefenster`;
|
||||
}
|
||||
|
||||
function toDate(isoDate: string): Date {
|
||||
return new Date(`${isoDate}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Erntefenster gibt es nur fuer Abo-Angebote mit Zyklus ERNTE. */
|
||||
async function loadHarvestOffer(offerId: string) {
|
||||
const offer = await prisma.sponsorshipOffer.findUnique({ where: { id: offerId }, select: { id: true, name: true, kind: true, cycle: true } });
|
||||
return offer && offer.kind === "ABO" && offer.cycle === "ERNTE" ? offer : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fenster desselben Angebots duerfen sich nicht ueberschneiden: sonst waere
|
||||
* unklar, in welchem Fenster eine Lieferung faellig wird, und die geplanten
|
||||
* Glaeser wuerden doppelt reserviert.
|
||||
*/
|
||||
async function overlaps(offerId: string, startsOn: Date, endsOn: Date, exceptId?: string): Promise<boolean> {
|
||||
const count = await prisma.harvestWindow.count({
|
||||
where: {
|
||||
offerId,
|
||||
...(exceptId ? { id: { not: exceptId } } : {}),
|
||||
startsOn: { lte: endsOn },
|
||||
endsOn: { gte: startsOn },
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
function parseWindow(formData: FormData) {
|
||||
return windowSchema.safeParse({
|
||||
startsOn: str(formData, "startsOn"),
|
||||
endsOn: str(formData, "endsOn"),
|
||||
plannedJars: str(formData, "plannedJars") || "0",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createHarvestWindowAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const offer = await loadHarvestOffer(str(formData, "offerId"));
|
||||
if (!offer) return fail("/admin/patenschaften/angebote", "Erntefenster gibt es nur für Abo-Angebote mit dem Zyklus „Nach der Ernte“.");
|
||||
const back = windowPath(offer.id);
|
||||
const parsed = parseWindow(formData);
|
||||
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
const startsOn = toDate(parsed.data.startsOn);
|
||||
const endsOn = toDate(parsed.data.endsOn);
|
||||
if (await overlaps(offer.id, startsOn, endsOn)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
|
||||
|
||||
const window = await prisma.harvestWindow.create({
|
||||
data: { offerId: offer.id, startsOn, endsOn, plannedJars: parsed.data.plannedJars },
|
||||
});
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest_window.create",
|
||||
entityType: "HarvestWindow",
|
||||
entityId: window.id,
|
||||
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
|
||||
});
|
||||
revalidatePath(back);
|
||||
return succeed(back, "Erntefenster angelegt.");
|
||||
}
|
||||
|
||||
export async function updateHarvestWindowAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const existing = await prisma.harvestWindow.findUnique({ where: { id }, select: { offerId: true } });
|
||||
const offer = existing ? await loadHarvestOffer(existing.offerId) : null;
|
||||
if (!existing || !offer) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden.");
|
||||
const back = windowPath(offer.id);
|
||||
const parsed = parseWindow(formData);
|
||||
if (!parsed.success) return fail(back, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
const startsOn = toDate(parsed.data.startsOn);
|
||||
const endsOn = toDate(parsed.data.endsOn);
|
||||
if (await overlaps(offer.id, startsOn, endsOn, id)) return fail(back, "Das Fenster überschneidet sich mit einem bestehenden Erntefenster.");
|
||||
|
||||
await prisma.harvestWindow.update({ where: { id }, data: { startsOn, endsOn, plannedJars: parsed.data.plannedJars } });
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest_window.update",
|
||||
entityType: "HarvestWindow",
|
||||
entityId: id,
|
||||
detail: `${offer.name}: ${parsed.data.startsOn} – ${parsed.data.endsOn}, ${parsed.data.plannedJars} Gläser`,
|
||||
});
|
||||
revalidatePath(back);
|
||||
return succeed(back, "Erntefenster gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteHarvestWindowAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const existing = await prisma.harvestWindow.findUnique({ where: { id }, include: { offer: { select: { name: true } } } });
|
||||
if (!existing) return fail("/admin/patenschaften/angebote", "Das Erntefenster wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||
const back = windowPath(existing.offerId);
|
||||
await prisma.harvestWindow.deleteMany({ where: { id } });
|
||||
await logAdminAction(admin, {
|
||||
action: "harvest_window.delete",
|
||||
entityType: "HarvestWindow",
|
||||
entityId: id,
|
||||
detail: `${existing.offer.name}: ${existing.startsOn.toISOString().slice(0, 10)} – ${existing.endsOn.toISOString().slice(0, 10)}`,
|
||||
});
|
||||
revalidatePath(back);
|
||||
return succeed(back, "Erntefenster gelöscht.");
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
import { isUniqueViolation } from "./sponsorship-shared";
|
||||
|
||||
const LOYALTY_PATH = "/admin/patenschaften/treuestufen";
|
||||
|
||||
const nameFields = {
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(60, "Name: höchstens 60 Zeichen."),
|
||||
nameEn: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(60, "Name (Englisch): höchstens 60 Zeichen.")
|
||||
.transform((value) => (value === "" ? null : value)),
|
||||
};
|
||||
|
||||
const thresholdField = z.coerce
|
||||
.number({ error: "Schwelle: bitte eine ganze Zahl." })
|
||||
.int("Schwelle: bitte eine ganze Zahl.")
|
||||
.min(1, "Schwelle: mindestens 1.")
|
||||
.max(1000, "Schwelle: höchstens 1000.");
|
||||
|
||||
const levelSchema = z.object({ ...nameFields, threshold: thresholdField });
|
||||
const newLevelSchema = levelSchema.extend({ kind: z.enum(["STAMMKUNDE", "ABO"], { error: "Unbekannte Stufenart." }) });
|
||||
|
||||
function readLevel(formData: FormData) {
|
||||
return {
|
||||
name: str(formData, "name"),
|
||||
nameEn: str(formData, "nameEn"),
|
||||
threshold: str(formData, "threshold"),
|
||||
};
|
||||
}
|
||||
|
||||
const DUPLICATE_THRESHOLD = "Für diese Schwelle gibt es bereits eine Stufe.";
|
||||
|
||||
export async function createLoyaltyLevelAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const parsed = newLevelSchema.safeParse({ ...readLevel(formData), kind: str(formData, "kind") });
|
||||
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
try {
|
||||
const level = await prisma.loyaltyLevel.create({ data: parsed.data });
|
||||
await logAdminAction(admin, {
|
||||
action: "loyalty_level.create",
|
||||
entityType: "LoyaltyLevel",
|
||||
entityId: level.id,
|
||||
detail: `${level.kind} ab ${level.threshold}: ${level.name}`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
|
||||
throw error;
|
||||
}
|
||||
revalidatePath(LOYALTY_PATH);
|
||||
return succeed(LOYALTY_PATH, "Stufe angelegt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert Namen und Schwelle einer Stufe. Die VIP-Stufe (Abo, ohne
|
||||
* Schwelle) behaelt ihre fehlende Schwelle — sie folgt immer nach der
|
||||
* hoechsten Zahl, nur ihr Name ist pflegbar.
|
||||
*/
|
||||
export async function updateLoyaltyLevelAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
|
||||
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden.");
|
||||
const isVip = existing.threshold === null;
|
||||
|
||||
const parsed = isVip
|
||||
? z.object(nameFields).safeParse(readLevel(formData))
|
||||
: levelSchema.safeParse(readLevel(formData));
|
||||
if (!parsed.success) return fail(LOYALTY_PATH, parsed.error.issues[0]?.message ?? "Ungültige Eingabe.");
|
||||
|
||||
try {
|
||||
await prisma.loyaltyLevel.update({ where: { id }, data: parsed.data });
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error)) return fail(LOYALTY_PATH, DUPLICATE_THRESHOLD);
|
||||
throw error;
|
||||
}
|
||||
const threshold = "threshold" in parsed.data ? parsed.data.threshold : null;
|
||||
await logAdminAction(admin, {
|
||||
action: "loyalty_level.update",
|
||||
entityType: "LoyaltyLevel",
|
||||
entityId: id,
|
||||
detail: `${existing.kind} ${existing.threshold ?? "VIP"} → ${threshold ?? "VIP"}: ${existing.name} → ${parsed.data.name}`,
|
||||
});
|
||||
revalidatePath(LOYALTY_PATH);
|
||||
return succeed(LOYALTY_PATH, "Stufe gespeichert.");
|
||||
}
|
||||
|
||||
export async function deleteLoyaltyLevelAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const existing = await prisma.loyaltyLevel.findUnique({ where: { id } });
|
||||
if (!existing) return fail(LOYALTY_PATH, "Die Stufe wurde nicht gefunden — möglicherweise bereits gelöscht.");
|
||||
if (existing.threshold === null) return fail(LOYALTY_PATH, "Die VIP-Stufe kann nicht gelöscht werden.");
|
||||
|
||||
await prisma.loyaltyLevel.deleteMany({ where: { id, threshold: { not: null } } });
|
||||
await logAdminAction(admin, {
|
||||
action: "loyalty_level.delete",
|
||||
entityType: "LoyaltyLevel",
|
||||
entityId: id,
|
||||
detail: `${existing.kind} ab ${existing.threshold}: ${existing.name}`,
|
||||
});
|
||||
revalidatePath(LOYALTY_PATH);
|
||||
return succeed(LOYALTY_PATH, "Stufe gelöscht.");
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { carrierPriceCents, parseOfferForm, type OfferFormValues } from "@/app/admin/patenschaften/_lib/offer-schema";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
import { isUniqueViolation, syncStripePriceSafely } from "./sponsorship-shared";
|
||||
|
||||
const OFFERS_PATH = "/admin/patenschaften/angebote";
|
||||
|
||||
function carrierSlug(slug: string): string {
|
||||
return `patenschaft-${slug}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traegerprodukt einer Einmal-Patenschaft (Kategorie PATENSCHAFT): haelt
|
||||
* Preis, Steuersatz und Rechnungsposition, damit Warenkorb, Checkout und
|
||||
* Rechnung keinen Sonderweg brauchen. Sein Preis ist Paketpreis minus
|
||||
* Honiganteil — der Honig wird im Checkout als eigene Position zum
|
||||
* Honig-Steuersatz angelegt.
|
||||
*/
|
||||
function carrierProductData(values: OfferFormValues) {
|
||||
return {
|
||||
name: `Bienenpatenschaft ${values.name}`,
|
||||
slug: carrierSlug(values.slug),
|
||||
category: "PATENSCHAFT" as const,
|
||||
description: values.description,
|
||||
weightGrams: 0,
|
||||
priceCents: carrierPriceCents(values),
|
||||
pricePerKgCents: 0,
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
|
||||
function offerData(values: OfferFormValues) {
|
||||
return {
|
||||
slug: values.slug,
|
||||
name: values.name,
|
||||
nameEn: values.nameEn,
|
||||
description: values.description,
|
||||
descriptionEn: values.descriptionEn,
|
||||
features: values.features,
|
||||
featuresEn: values.featuresEn,
|
||||
priceCents: values.priceCents,
|
||||
isActive: values.isActive,
|
||||
sortOrder: values.sortOrder,
|
||||
isPhysical: values.isPhysical,
|
||||
honeyProductId: values.honeyProductId,
|
||||
honeyJars: values.honeyJars,
|
||||
honeyShareCentsPerJar: values.honeyShareCentsPerJar,
|
||||
honeyShareCentsPerMonth: values.honeyShareCentsPerMonth,
|
||||
cycle: values.cycle,
|
||||
maxActiveSubscriptions: values.maxActiveSubscriptions,
|
||||
medalTier: values.medalTier,
|
||||
} satisfies Prisma.SponsorshipOfferUncheckedUpdateInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pruefungen gegen die Datenbank: eindeutige Adressen, ein echtes
|
||||
* Honigprodukt — und die Aktivierungssperre: ein Angebot mit Honig laesst
|
||||
* sich nur aktivieren, wenn das Honigprodukt existiert und aktiv ist. Sonst
|
||||
* bekaeme die Kundschaft eine Urkunde ohne den bezahlten Honig.
|
||||
*/
|
||||
async function findConflict(
|
||||
values: OfferFormValues,
|
||||
existing: { id: string; productId: string | null } | null
|
||||
): Promise<string | null> {
|
||||
const slugOwner = await prisma.sponsorshipOffer.findUnique({ where: { slug: values.slug }, select: { id: true } });
|
||||
if (slugOwner && slugOwner.id !== existing?.id) return `Die Adresse "${values.slug}" hat bereits ein anderes Angebot.`;
|
||||
|
||||
if (values.kind === "EINMALIG") {
|
||||
const product = await prisma.product.findUnique({ where: { slug: carrierSlug(values.slug) }, select: { id: true } });
|
||||
if (product && product.id !== existing?.productId) {
|
||||
return `Die Produktadresse "${carrierSlug(values.slug)}" ist bereits vergeben. Bitte eine andere Adresse wählen.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (values.honeyProductId) {
|
||||
const honey = await prisma.product.findUnique({
|
||||
where: { id: values.honeyProductId },
|
||||
select: { category: true, isActive: true, name: true },
|
||||
});
|
||||
if (!honey || honey.category !== "HONIG") return "Bitte ein Honigprodukt aus der Liste wählen.";
|
||||
if (values.isActive && !honey.isActive) {
|
||||
return `Das Angebot kann nicht aktiviert werden: das Honigprodukt "${honey.name}" ist inaktiv.`;
|
||||
}
|
||||
} else if (values.isActive && (values.isPhysical || values.honeyJars > 0)) {
|
||||
return "Ein physisches Angebot ohne Honigprodukt kann nicht aktiviert werden.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function auditDetail(values: OfferFormValues): string {
|
||||
const state = values.isActive ? "aktiv" : "inaktiv";
|
||||
return `${values.name} (${values.kind}, ${formatPrice(values.priceCents)}, ${state})`;
|
||||
}
|
||||
|
||||
/** Nach dem Speichern eines Abo-Angebots: Stripe-Preis nachziehen und passend melden. */
|
||||
async function finishSave(offerId: string, values: OfferFormValues, message: string): Promise<never> {
|
||||
revalidatePath(OFFERS_PATH);
|
||||
revalidatePath("/patenschaft");
|
||||
const target = `${OFFERS_PATH}/${offerId}`;
|
||||
if (values.kind === "ABO") {
|
||||
const stripeError = await syncStripePriceSafely(offerId);
|
||||
if (stripeError) {
|
||||
return fail(target, `${message} Der Stripe-Preis konnte aber nicht angelegt werden (${stripeError}). Bitte später erneut speichern.`);
|
||||
}
|
||||
}
|
||||
return succeed(target, message);
|
||||
}
|
||||
|
||||
export async function createSponsorshipOfferAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const back = `${OFFERS_PATH}/neu`;
|
||||
const parsed = parseOfferForm(formData);
|
||||
if (!parsed.ok) return fail(back, parsed.message);
|
||||
const values = parsed.value;
|
||||
|
||||
const conflict = await findConflict(values, null);
|
||||
if (conflict) return fail(back, conflict);
|
||||
|
||||
let offerId: string;
|
||||
try {
|
||||
offerId = await prisma.$transaction(async (tx) => {
|
||||
const productId = values.kind === "EINMALIG" ? (await tx.product.create({ data: carrierProductData(values) })).id : null;
|
||||
const offer = await tx.sponsorshipOffer.create({ data: { ...offerData(values), kind: values.kind, productId } });
|
||||
return offer.id;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
|
||||
throw error;
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "sponsorship_offer.create",
|
||||
entityType: "SponsorshipOffer",
|
||||
entityId: offerId,
|
||||
detail: auditDetail(values),
|
||||
});
|
||||
return finishSave(offerId, values, "Angebot angelegt.");
|
||||
}
|
||||
|
||||
export async function updateSponsorshipOfferAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const existing = await prisma.sponsorshipOffer.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, kind: true, productId: true, isActive: true, priceCents: true },
|
||||
});
|
||||
if (!existing) return fail(OFFERS_PATH, "Das Angebot wurde nicht gefunden.");
|
||||
const back = `${OFFERS_PATH}/${id}`;
|
||||
|
||||
const parsed = parseOfferForm(formData, existing.kind);
|
||||
if (!parsed.ok) return fail(back, parsed.message);
|
||||
const values = parsed.value;
|
||||
|
||||
const conflict = await findConflict(values, existing);
|
||||
if (conflict) return fail(back, conflict);
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
let productId = existing.productId;
|
||||
if (values.kind === "EINMALIG") {
|
||||
// Fehlt das Traegerprodukt (Altbestand), wird es hier nachgeholt —
|
||||
// ohne Produkt ist eine Einmal-Patenschaft nicht bestellbar.
|
||||
productId = productId
|
||||
? (await tx.product.update({ where: { id: productId }, data: carrierProductData(values) })).id
|
||||
: (await tx.product.create({ data: carrierProductData(values) })).id;
|
||||
}
|
||||
await tx.sponsorshipOffer.update({ where: { id }, data: { ...offerData(values), productId } });
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (isUniqueViolation(error)) return fail(back, "Adresse bereits vergeben — bitte eine andere wählen.");
|
||||
throw error;
|
||||
}
|
||||
|
||||
const changes = [
|
||||
existing.isActive !== values.isActive ? (values.isActive ? "aktiviert" : "deaktiviert") : null,
|
||||
existing.priceCents !== values.priceCents ? `Preis ${formatPrice(existing.priceCents)} → ${formatPrice(values.priceCents)}` : null,
|
||||
].filter(Boolean);
|
||||
await logAdminAction(admin, {
|
||||
action: "sponsorship_offer.update",
|
||||
entityType: "SponsorshipOffer",
|
||||
entityId: id,
|
||||
detail: `${auditDetail(values)}${changes.length > 0 ? `; ${changes.join(", ")}` : ""}`,
|
||||
});
|
||||
return finishSave(id, values, "Angebot gespeichert.");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { syncOfferStripePrice } from "@/lib/sponsorship-subscription/stripe-prices";
|
||||
|
||||
/**
|
||||
* Gemeinsame Helfer der Patenschafts-Actions (bewusst ohne "use server":
|
||||
* hier liegen keine aufrufbaren Endpunkte, nur Bausteine).
|
||||
*/
|
||||
|
||||
/** Prisma-Fehler P2002: eindeutiger Wert (Slug, Schwelle) bereits vergeben, etwa durch einen parallelen Speichervorgang. */
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "P2002";
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt Stripe-Produkt und -Preis eines Abo-Angebots an bzw. zieht sie nach.
|
||||
* Scheitert das (Stripe nicht erreichbar, kein Schluessel), bleibt das
|
||||
* Angebot gespeichert — der Admin bekommt eine klare Meldung und kann erneut
|
||||
* speichern; syncOfferStripePrice ist idempotent. Gibt die Fehlermeldung
|
||||
* zurueck oder null.
|
||||
*/
|
||||
export async function syncStripePriceSafely(offerId: string): Promise<string | null> {
|
||||
try {
|
||||
await syncOfferStripePrice(offerId);
|
||||
return null;
|
||||
} catch (error: unknown) {
|
||||
const message = errorMessage(error);
|
||||
console.error(`[patenschaft] Stripe-Preis fuer Angebot ${offerId} nicht synchronisiert: ${message}`);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ganze Zahl aus einem Formularfeld, sonst null (leer, Komma, Buchstaben). */
|
||||
export function intOrNull(value: string): number | null {
|
||||
return /^\d+$/.test(value.trim()) ? Number(value.trim()) : null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { MAX_SEPA_HOLD_DAYS, SEPA_HOLD_DAYS_KEY, getSepaHoldDays } from "@/lib/sponsorship/settings";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
import { intOrNull } from "./sponsorship-shared";
|
||||
|
||||
const SUBSCRIPTIONS_PATH = "/admin/patenschaften/abos";
|
||||
|
||||
/**
|
||||
* Hebt die SEPA-Sperre eines Kontos auf (gesetzt nach einer Rueckbuchung).
|
||||
* Danach kann das Konto neue Abos wieder per Lastschrift abschliessen.
|
||||
*/
|
||||
export async function liftSepaBlockAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const customerId = str(formData, "customerId");
|
||||
const customer = await prisma.customer.findUnique({ where: { id: customerId }, select: { email: true, sepaBlockedAt: true } });
|
||||
if (!customer) return fail(SUBSCRIPTIONS_PATH, "Das Konto wurde nicht gefunden.");
|
||||
if (!customer.sepaBlockedAt) return succeed(SUBSCRIPTIONS_PATH, "Für dieses Konto besteht keine SEPA-Sperre.");
|
||||
|
||||
await prisma.customer.update({ where: { id: customerId }, data: { sepaBlockedAt: null } });
|
||||
await logAdminAction(admin, {
|
||||
action: "customer.sepa_unblock",
|
||||
entityType: "Customer",
|
||||
entityId: customerId,
|
||||
detail: `${customer.email} (gesperrt seit ${customer.sepaBlockedAt.toISOString().slice(0, 10)})`,
|
||||
});
|
||||
revalidatePath(SUBSCRIPTIONS_PATH);
|
||||
return succeed(SUBSCRIPTIONS_PATH, `SEPA-Sperre für ${customer.email} aufgehoben.`);
|
||||
}
|
||||
|
||||
export async function updateSepaHoldDaysAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const days = intOrNull(str(formData, "sepaHoldDays"));
|
||||
if (days === null || days > MAX_SEPA_HOLD_DAYS) {
|
||||
return fail(SUBSCRIPTIONS_PATH, `Bitte eine ganze Zahl von 0 bis ${MAX_SEPA_HOLD_DAYS} Tagen angeben.`);
|
||||
}
|
||||
const previous = await getSepaHoldDays();
|
||||
await prisma.siteContent.upsert({
|
||||
where: { key: SEPA_HOLD_DAYS_KEY },
|
||||
create: { key: SEPA_HOLD_DAYS_KEY, valueDe: String(days) },
|
||||
update: { valueDe: String(days) },
|
||||
});
|
||||
await logAdminAction(admin, {
|
||||
action: "sponsorship.sepa_hold_days",
|
||||
entityType: "SiteContent",
|
||||
entityId: SEPA_HOLD_DAYS_KEY,
|
||||
detail: `${previous} → ${days} Tage`,
|
||||
});
|
||||
revalidatePath(SUBSCRIPTIONS_PATH);
|
||||
return succeed(SUBSCRIPTIONS_PATH, days === 0 ? "SEPA-Neukunden-Frist abgeschaltet." : `SEPA-Neukunden-Frist auf ${days} Tage gesetzt.`);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logAdminAction } from "@/lib/audit-log";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { defaultLocale, isLocale } from "@/i18n/config";
|
||||
import { processSponsorship, resetSponsorshipForRetry, type ProcessResult } from "@/lib/sponsorship/issue";
|
||||
import { sponsorshipReadyEmail } from "@/lib/sponsorship/emails";
|
||||
import { reviseSponsorship, type ReviseSponsorshipError } from "@/lib/sponsorship/revise";
|
||||
import { beeNameFromJson } from "@/app/admin/patenschaften/_lib/revision-data";
|
||||
import { requireAdmin, fail, succeed, str } from "./_shared";
|
||||
|
||||
const LIST_PATH = "/admin/patenschaften";
|
||||
|
||||
function detailPath(id: string): string {
|
||||
return `${LIST_PATH}/${id}`;
|
||||
}
|
||||
|
||||
export async function markSponsorshipPrintedAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
// Atomar an den Zustand gebunden: ein Doppelklick oder ein zweiter Tab
|
||||
// setzt printedAt nicht ein zweites Mal neu.
|
||||
const updated = await prisma.sponsorship.updateMany({
|
||||
where: { id, status: "BEREIT", printedAt: null, offer: { isPhysical: true } },
|
||||
data: { status: "GEDRUCKT", printedAt: new Date() },
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
return fail(detailPath(id), "Nur bereite, noch nicht gedruckte Urkunden physischer Pakete lassen sich als gedruckt markieren.");
|
||||
}
|
||||
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true } });
|
||||
await logAdminAction(admin, {
|
||||
action: "sponsorship.mark_printed",
|
||||
entityType: "Sponsorship",
|
||||
entityId: id,
|
||||
detail: sponsorship?.number,
|
||||
});
|
||||
revalidatePath(LIST_PATH);
|
||||
return succeed(detailPath(id), "Als gedruckt markiert.");
|
||||
}
|
||||
|
||||
const PROCESS_MESSAGES: Record<ProcessResult, string> = {
|
||||
ready: "Urkunde erzeugt und Mail versendet.",
|
||||
retry: "Der Versuch ist fehlgeschlagen. Der nächste automatische Versuch ist eingeplant — Fehler siehe unten.",
|
||||
failed: "Der Versuch ist fehlgeschlagen. Alle automatischen Versuche sind aufgebraucht — Fehler siehe unten.",
|
||||
missing: "Die Patenschaft wurde nicht gefunden.",
|
||||
};
|
||||
|
||||
/**
|
||||
* "Erneut erzeugen": bei FEHLER werden die Versuche zurueckgesetzt, bei
|
||||
* AUSSTEHEND wird nur sofort ein Versuch gestartet, statt auf den CronJob zu
|
||||
* warten. Beides laeuft ueber processSponsorship — Revision 1 entsteht dort
|
||||
* nur, solange es noch keine gibt, eine Wiederholung erzeugt also nie eine
|
||||
* zweite Urkunde.
|
||||
*/
|
||||
export async function retrySponsorshipAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const sponsorship = await prisma.sponsorship.findUnique({ where: { id }, select: { number: true, status: true } });
|
||||
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
|
||||
if (sponsorship.status !== "FEHLER" && sponsorship.status !== "AUSSTEHEND") {
|
||||
return fail(detailPath(id), "Die Urkunde ist bereits erzeugt.");
|
||||
}
|
||||
if (sponsorship.status === "FEHLER") await resetSponsorshipForRetry(id);
|
||||
const result = await processSponsorship(id);
|
||||
await logAdminAction(admin, {
|
||||
action: "sponsorship.retry",
|
||||
entityType: "Sponsorship",
|
||||
entityId: id,
|
||||
detail: `${sponsorship.number}: ${sponsorship.status} → ${result}`,
|
||||
});
|
||||
revalidatePath(LIST_PATH);
|
||||
if (result !== "ready") return fail(detailPath(id), PROCESS_MESSAGES[result]);
|
||||
return succeed(detailPath(id), PROCESS_MESSAGES.ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Mail erneut senden" — nur der Versand, sonst nichts. Bewusst NICHT ueber
|
||||
* processSponsorship: das bricht bei BEREIT/GEDRUCKT sofort ab, und den
|
||||
* Status dafuer zurueckzusetzen wuerde einen gedruckten Stand verlieren.
|
||||
* Revisionen werden hier weder gelesen noch angelegt, ausser zum Auslesen
|
||||
* des aktuellen Bienennamens.
|
||||
*/
|
||||
export async function resendSponsorshipMailAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const sponsorship = await prisma.sponsorship.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
status: true,
|
||||
locale: true,
|
||||
currentRevision: true,
|
||||
owner: { select: { email: true, firstName: true } },
|
||||
},
|
||||
});
|
||||
if (!sponsorship) return fail(LIST_PATH, "Die Patenschaft wurde nicht gefunden.");
|
||||
if ((sponsorship.status !== "BEREIT" && sponsorship.status !== "GEDRUCKT") || sponsorship.currentRevision === 0) {
|
||||
return fail(detailPath(id), "Die Mail lässt sich erst erneut senden, wenn die Urkunde zugestellt wurde.");
|
||||
}
|
||||
const revision = await prisma.sponsorshipRevision.findUnique({
|
||||
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
|
||||
select: { personalization: true },
|
||||
});
|
||||
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
|
||||
const { subject, html, text } = await sponsorshipReadyEmail({
|
||||
locale,
|
||||
firstName: sponsorship.owner.firstName,
|
||||
beeName: beeNameFromJson(revision?.personalization) ?? "",
|
||||
number: sponsorship.number,
|
||||
accountUrl: `${getBaseUrl()}/konto/patenschaft/${sponsorship.id}`,
|
||||
});
|
||||
const sent = await sendMail({ to: sponsorship.owner.email, subject, html, text });
|
||||
if (!sent) return fail(detailPath(id), MAIL_FAILED_HINT);
|
||||
|
||||
await prisma.sponsorship.update({ where: { id }, data: { mailSentAt: new Date() } });
|
||||
await logAdminAction(admin, {
|
||||
action: "sponsorship.resend_mail",
|
||||
entityType: "Sponsorship",
|
||||
entityId: id,
|
||||
detail: `${sponsorship.number} an ${sponsorship.owner.email}`,
|
||||
});
|
||||
return succeed(detailPath(id), `Mail erneut an ${sponsorship.owner.email} gesendet.`);
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
beeName: "Name der Patenbiene",
|
||||
recipientName: "Name auf der Urkunde",
|
||||
giverNames: "Schenkende",
|
||||
dedication: "Widmung",
|
||||
occasion: "Anlass",
|
||||
startDate: "Startdatum",
|
||||
layout: "Gesamtlayout",
|
||||
};
|
||||
|
||||
const SCHEMA_MESSAGES: Record<string, string> = {
|
||||
required: "ist ein Pflichtfeld",
|
||||
too_short: "ist zu kurz",
|
||||
too_long: "ist zu lang",
|
||||
too_many_line_breaks: "hat zu viele Zeilenumbrüche",
|
||||
no_letter: "braucht mindestens einen Buchstaben",
|
||||
invalid_date: "ist kein gültiges Datum",
|
||||
};
|
||||
|
||||
function revisionErrorMessage(error: ReviseSponsorshipError): string {
|
||||
switch (error.code) {
|
||||
case "not_found":
|
||||
return "Die Patenschaft wurde nicht gefunden.";
|
||||
case "no_revision":
|
||||
return "Die Urkunde wurde noch nicht erzeugt — korrigieren lässt sich erst eine bestehende Urkunde.";
|
||||
case "reason":
|
||||
return `Bitte einen Änderungsgrund angeben (${error.min}–${error.max} Zeichen).`;
|
||||
case "unknown_template":
|
||||
return "Unbekannte Vorlagenversion.";
|
||||
case "unchanged":
|
||||
return "Keine Änderung gegenüber der aktuellen Revision.";
|
||||
case "start_date_out_of_range":
|
||||
return `Das Startdatum muss zwischen ${error.min} und ${error.max} liegen.`;
|
||||
case "invalid": {
|
||||
const label = error.field ? FIELD_LABELS[error.field] : "Eingabe";
|
||||
return `${label} ${SCHEMA_MESSAGES[error.message] ?? "ist ungültig"}.`;
|
||||
}
|
||||
case "glyphs":
|
||||
return `${FIELD_LABELS[error.field] ?? "Text"}: diese Zeichen kann die Urkunde nicht drucken: ${error.characters.join(" ")}`;
|
||||
case "fit":
|
||||
return `${FIELD_LABELS[error.field] ?? "Text"} passt so nicht auf die Urkunde — bitte kürzen.`;
|
||||
case "conflict":
|
||||
return "Die Urkunde wurde gerade von jemand anderem korrigiert. Bitte die Seite neu laden und erneut prüfen.";
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviseSponsorshipAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const back = `${detailPath(id)}/korrigieren`;
|
||||
const result = await reviseSponsorship({
|
||||
sponsorshipId: id,
|
||||
personalization: {
|
||||
beeName: String(formData.get("beeName") ?? ""),
|
||||
recipientName: String(formData.get("recipientName") ?? ""),
|
||||
giverNames: String(formData.get("giverNames") ?? ""),
|
||||
dedication: String(formData.get("dedication") ?? ""),
|
||||
occasion: str(formData, "occasion"),
|
||||
startDate: str(formData, "startDate"),
|
||||
},
|
||||
reason: String(formData.get("reason") ?? ""),
|
||||
templateVersion: str(formData, "templateVersion") || null,
|
||||
notifyCustomer: formData.get("notifyCustomer") === "on",
|
||||
admin,
|
||||
});
|
||||
if (!result.ok) return fail(back, revisionErrorMessage(result.error));
|
||||
|
||||
revalidatePath(LIST_PATH);
|
||||
const message = `Revision ${result.revision} gespeichert.`;
|
||||
if (result.mailSent === false) return fail(detailPath(id), `${message} ${MAIL_FAILED_HINT}`);
|
||||
return succeed(detailPath(id), result.mailSent ? `${message} Kunde benachrichtigt.` : message);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { checkUploadFiles } from "@/lib/upload-health";
|
||||
import { checkUploadVolume } from "@/lib/uploads";
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
import { HexMark } from "@/components/honeycomb";
|
||||
import { getSponsorshipAttentionItems } from "@/app/admin/patenschaften/_lib/attention";
|
||||
|
||||
const DASHBOARD_ICON_PROPS = {
|
||||
width: 20,
|
||||
@@ -266,7 +267,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
const stockRejected = stockIssues.rejected;
|
||||
const unlinkedProducts = stockIssues.unlinked;
|
||||
|
||||
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
|
||||
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries, sponsorshipItems] = await Promise.all([
|
||||
getCurrentAdmin(),
|
||||
prisma.order.count({
|
||||
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
|
||||
@@ -274,6 +275,9 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
|
||||
prisma.review.count({ where: pendingReviewWhere() }),
|
||||
prisma.businessInquiry.count({ where: { status: "NEU" } }),
|
||||
// Bienenpatenschaft: gescheiterte Ausstellung, liegengebliebener Druck,
|
||||
// Honigdeckung der Abos, Abos im Verzug (siehe patenschaften/_lib/attention.ts).
|
||||
getSponsorshipAttentionItems(locale),
|
||||
]);
|
||||
const toDo = [
|
||||
{ count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
|
||||
@@ -283,6 +287,7 @@ export default async function AdminDashboardPage(props: PageProps<"/admin">) {
|
||||
{ count: stockRejected, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoStockRejected(stockRejected) },
|
||||
{ count: stockOverdue, href: "/admin", label: t.adminDashboardPage.toDoStockOverdue(stockOverdue) },
|
||||
{ count: unlinkedProducts, href: "/admin/produkte", label: t.adminDashboardPage.toDoUnlinkedProducts(unlinkedProducts) },
|
||||
...sponsorshipItems,
|
||||
].filter((item) => item.count > 0);
|
||||
|
||||
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { defaultLocale, isLocale } from "@/i18n/config";
|
||||
import { personalizationSchema } from "@/lib/certificate/schema";
|
||||
import { checkCertificateFits } from "@/lib/certificate/layout";
|
||||
import { listTemplateVersions } from "@/lib/certificate/templates";
|
||||
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
|
||||
import { REVISABLE_FIELDS, revisionTerm } from "@/lib/sponsorship/revise";
|
||||
import { getAuthorizedAdmin, notFoundResponse } from "../../_lib/admin-guard";
|
||||
|
||||
const PREVIEW_WIDTH_PX = 900;
|
||||
const WATERMARK = "Korrekturvorschau";
|
||||
/** Ein Korrekturformular ist klein; alles darueber ist kein legitimer Aufruf. */
|
||||
const MAX_BODY_BYTES = 8 * 1024;
|
||||
|
||||
function invalid(body: Record<string, unknown>): Response {
|
||||
return Response.json(body, { status: 422, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Vorschau der Korrektur: dieselbe Pruefung (Schema, Glyphen, Platz)
|
||||
* und dieselbe Laufzeitregel wie reviseSponsorship, gerendert mit dem
|
||||
* Wasserzeichen "Korrekturvorschau". Ungueltige Eingaben ergeben 422 mit
|
||||
* Feld und Fehlercode, damit das Formular die Stelle markieren kann.
|
||||
* Nur fuer Admins mit 2FA, sonst 404.
|
||||
*/
|
||||
export async function POST(request: Request, ctx: RouteContext<"/admin/patenschaften/[id]/korrekturvorschau">): Promise<Response> {
|
||||
if (!(await getAuthorizedAdmin())) return notFoundResponse();
|
||||
const { id } = await ctx.params;
|
||||
const sponsorship = await prisma.sponsorship.findUnique({
|
||||
where: { id },
|
||||
select: { number: true, locale: true, startDate: true, endDate: true, subscriptionId: true, currentRevision: true },
|
||||
});
|
||||
if (!sponsorship || sponsorship.currentRevision === 0) return notFoundResponse();
|
||||
|
||||
const raw = await request.text();
|
||||
if (raw.length > MAX_BODY_BYTES) return invalid({ code: "too_large" });
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
const parsedBody: unknown = JSON.parse(raw);
|
||||
body = parsedBody && typeof parsedBody === "object" ? (parsedBody as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return invalid({ code: "bad_json" });
|
||||
}
|
||||
|
||||
const isSubscription = sponsorship.subscriptionId !== null;
|
||||
const startDate = sponsorship.startDate.toISOString().slice(0, 10);
|
||||
const parsed = personalizationSchema.safeParse(isSubscription ? { ...body, startDate } : body);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
const field = REVISABLE_FIELDS.find((name) => name === issue?.path[0]) ?? null;
|
||||
return invalid({ field, code: issue?.message ?? "invalid" });
|
||||
}
|
||||
const templateVersion = typeof body.templateVersion === "string" ? body.templateVersion : "";
|
||||
if (!listTemplateVersions().includes(templateVersion)) return invalid({ field: null, code: "unknown_template" });
|
||||
|
||||
const locale = isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale;
|
||||
const term = revisionTerm({
|
||||
isSubscription,
|
||||
personalization: parsed.data,
|
||||
startDate: sponsorship.startDate,
|
||||
endDate: sponsorship.endDate,
|
||||
});
|
||||
const fit = checkCertificateFits(parsed.data, locale, { term, templateVersion, number: sponsorship.number });
|
||||
if (!fit.ok) {
|
||||
return invalid({ field: fit.field, code: fit.reason, characters: fit.reason === "glyphs" ? fit.characters : [] });
|
||||
}
|
||||
|
||||
const image = await renderCertificateDocumentImage(
|
||||
{ personalization: parsed.data, locale, term, number: sponsorship.number, templateVersion },
|
||||
{ widthPx: PREVIEW_WIDTH_PX, format: "webp", watermark: WATERMARK }
|
||||
);
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: { "Content-Type": "image/webp", "Cache-Control": "private, no-store" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { startDateRange } from "@/lib/certificate/schema";
|
||||
import { CURRENT_TEMPLATE_VERSION } from "@/lib/certificate/templates";
|
||||
import { parseStoredPersonalization, toIsoDate } from "@/lib/sponsorship/certificate-document";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { reviseSponsorshipAction } from "@/app/admin/actions";
|
||||
import { CorrectionForm } from "../../_components/correction-form";
|
||||
|
||||
export default async function AdminSponsorshipCorrectionPage(props: PageProps<"/admin/patenschaften/[id]/korrigieren">) {
|
||||
const locale = await getLocale();
|
||||
const t = getSponsorshipAdminStrings(locale).correction;
|
||||
const { id } = await props.params;
|
||||
const sponsorship = await prisma.sponsorship.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
currentRevision: true,
|
||||
subscriptionId: true,
|
||||
createdAt: true,
|
||||
orderItem: { select: { order: { select: { createdAt: true } } } },
|
||||
},
|
||||
});
|
||||
if (!sponsorship || sponsorship.currentRevision === 0) notFound();
|
||||
const revision = await prisma.sponsorshipRevision.findUnique({
|
||||
where: { sponsorshipId_revision: { sponsorshipId: id, revision: sponsorship.currentRevision } },
|
||||
select: { personalization: true, templateVersion: true },
|
||||
});
|
||||
if (!revision) notFound();
|
||||
|
||||
const current = parseStoredPersonalization({ personalization: revision.personalization, templateVersion: revision.templateVersion });
|
||||
const isSubscription = sponsorship.subscriptionId !== null;
|
||||
// Dasselbe Fenster wie beim Kauf, gerechnet ab dem Bestelltag (reviseSponsorship prueft es erneut).
|
||||
const orderDate = toIsoDate(sponsorship.orderItem?.order.createdAt ?? sponsorship.createdAt);
|
||||
const templateOptions = [
|
||||
{ value: current.templateVersion, label: t.templatePrevious(current.templateVersion) },
|
||||
...(current.templateVersion !== CURRENT_TEMPLATE_VERSION
|
||||
? [{ value: CURRENT_TEMPLATE_VERSION, label: t.templateLatest(CURRENT_TEMPLATE_VERSION) }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
<BackLink href={`/admin/patenschaften/${id}`} label={t.backToDetail} />
|
||||
<h1 className="mb-2 text-2xl text-heading">{t.title(sponsorship.number)}</h1>
|
||||
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.intro}</p>
|
||||
<CorrectionForm
|
||||
sponsorshipId={id}
|
||||
locale={locale}
|
||||
action={reviseSponsorshipAction}
|
||||
isSubscription={isSubscription}
|
||||
startRange={isSubscription ? null : startDateRange(orderDate)}
|
||||
templateOptions={templateOptions}
|
||||
initial={{
|
||||
beeName: current.personalization.beeName,
|
||||
recipientName: current.personalization.recipientName,
|
||||
giverNames: current.personalization.giverNames ?? "",
|
||||
dedication: current.personalization.dedication ?? "",
|
||||
occasion: current.personalization.occasion,
|
||||
startDate: current.personalization.startDate,
|
||||
templateVersion: current.templateVersion,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminBadge } from "@/components/admin-list";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { SponsorshipStatusBadge } from "../_components/status-badge";
|
||||
import { SponsorshipActions } from "../_components/sponsorship-actions";
|
||||
import { RevisionTable, TransferTable } from "../_components/history-tables";
|
||||
import { beeNameFromJson } from "../_lib/revision-data";
|
||||
|
||||
// A4 hochkant: 595.28 × 841.89 pt — die Vorschau wird in 900 px Breite gerendert.
|
||||
const PREVIEW_WIDTH = 900;
|
||||
const PREVIEW_HEIGHT = Math.round((PREVIEW_WIDTH * 841.89) / 595.28);
|
||||
|
||||
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminSponsorshipDetailPage(props: PageProps<"/admin/patenschaften/[id]">) {
|
||||
const locale = await getLocale();
|
||||
const t = getSponsorshipAdminStrings(locale);
|
||||
const td = t.detail;
|
||||
const { id } = await props.params;
|
||||
const sponsorship = await prisma.sponsorship.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: { select: { id: true, email: true, firstName: true, lastName: true } },
|
||||
offer: { select: { id: true, name: true, kind: true, isPhysical: true } },
|
||||
orderItem: { select: { order: { select: { id: true, orderNumber: true } } } },
|
||||
subscription: { select: { id: true, status: true } },
|
||||
revisions: { orderBy: { revision: "desc" } },
|
||||
transfers: { orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
if (!sponsorship) notFound();
|
||||
|
||||
const accountIds = [...new Set(sponsorship.transfers.flatMap((row) => [row.createdById, row.claimedById ?? ""]).filter(Boolean))];
|
||||
const accounts =
|
||||
accountIds.length > 0
|
||||
? await prisma.customer.findMany({ where: { id: { in: accountIds } }, select: { id: true, email: true } })
|
||||
: [];
|
||||
const emailOf = new Map(accounts.map((account) => [account.id, account.email]));
|
||||
const current = sponsorship.revisions.find((row) => row.revision === sponsorship.currentRevision) ?? null;
|
||||
const beeName = beeNameFromJson(current?.personalization) ?? "—";
|
||||
const start = formatDate(sponsorship.startDate, locale);
|
||||
const order = sponsorship.orderItem?.order ?? null;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<BackLink href="/admin/patenschaften" label={td.backToList} />
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-ink/60">{sponsorship.number}</p>
|
||||
<h1 className="mt-1 font-serif text-3xl text-heading">{beeName}</h1>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<SponsorshipStatusBadge status={sponsorship.status} label={t.statuses[sponsorship.status]} />
|
||||
<AdminBadge tone="neutral">{t.kinds[sponsorship.offer.kind]}</AdminBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Panel title={td.ownerHeading}>
|
||||
<p>
|
||||
{sponsorship.owner.firstName} {sponsorship.owner.lastName}
|
||||
</p>
|
||||
<p className="break-all text-sm text-ink/70">{sponsorship.owner.email}</p>
|
||||
<Link href={`/admin/benutzer/${sponsorship.owner.id}`} className="link-draw mt-2 inline-block text-sm text-heading">
|
||||
{td.openAccount}
|
||||
</Link>
|
||||
</Panel>
|
||||
<Panel title={td.sourceHeading}>
|
||||
{order ? (
|
||||
<Link href={`/admin/bestellungen/${order.id}`} className="link-draw text-heading">
|
||||
{td.order(order.orderNumber)}
|
||||
</Link>
|
||||
) : sponsorship.subscription ? (
|
||||
<Link href="/admin/patenschaften/abos" className="link-draw text-heading">
|
||||
{td.subscription} · {t.subscriptionStatuses[sponsorship.subscription.status]}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm text-ink/65">{td.noSource}</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title={td.offerHeading}>
|
||||
<Link href={`/admin/patenschaften/angebote/${sponsorship.offer.id}`} className="link-draw text-heading">
|
||||
{sponsorship.offer.name}
|
||||
</Link>
|
||||
<p className="mt-1 text-sm text-ink/65">{sponsorship.offer.isPhysical ? td.physical : td.digital}</p>
|
||||
</Panel>
|
||||
<Panel title={td.termHeading}>
|
||||
<p className="tabular-nums">
|
||||
{sponsorship.endDate ? t.common.range(start, formatDate(sponsorship.endDate, locale)) : td.termOpen(start)}
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-6 lg:grid-cols-5">
|
||||
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
|
||||
<h2 className="mb-4 text-xs tracking-wide-caps text-ink/65">{td.previewHeading}</h2>
|
||||
{current ? (
|
||||
<Image
|
||||
src={`/admin/patenschaften/${sponsorship.id}/vorschau.webp?r=${sponsorship.currentRevision}`}
|
||||
alt={td.previewAlt(beeName, sponsorship.currentRevision)}
|
||||
width={PREVIEW_WIDTH}
|
||||
height={PREVIEW_HEIGHT}
|
||||
unoptimized
|
||||
className="h-auto w-full border border-ink/10 shadow-[0_18px_40px_-28px_rgba(36,53,43,0.6)]"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-ink/65">{td.noRevision}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:col-span-3">
|
||||
<Panel title={td.actionsHeading}>
|
||||
<SponsorshipActions
|
||||
id={sponsorship.id}
|
||||
status={sponsorship.status}
|
||||
currentRevision={sponsorship.currentRevision}
|
||||
isPhysical={sponsorship.offer.isPhysical}
|
||||
printed={sponsorship.printedAt !== null}
|
||||
ownerEmail={sponsorship.owner.email}
|
||||
t={td}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title={td.deliveryHeading}>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li className="tabular-nums">{td.attempts(sponsorship.pdfAttempts, MAX_ISSUE_ATTEMPTS)}</li>
|
||||
{sponsorship.nextAttemptAt ? <li>{td.nextAttempt(formatDateTime(sponsorship.nextAttemptAt, locale))}</li> : null}
|
||||
<li>{sponsorship.mailSentAt ? td.mailSent(formatDateTime(sponsorship.mailSentAt, locale)) : td.mailNotSent}</li>
|
||||
{sponsorship.offer.isPhysical ? (
|
||||
<li>{sponsorship.printedAt ? td.printed(formatDateTime(sponsorship.printedAt, locale)) : td.notPrinted}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
{sponsorship.lastError ? (
|
||||
<div className="mt-4 border border-red-200 bg-red-50 p-3">
|
||||
<p className="text-xs tracking-wide-caps text-red-700">{td.lastError}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words font-mono text-xs text-red-800">{sponsorship.lastError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Panel title={td.revisionsHeading}>
|
||||
<RevisionTable
|
||||
sponsorshipId={sponsorship.id}
|
||||
revisions={sponsorship.revisions}
|
||||
currentRevision={sponsorship.currentRevision}
|
||||
locale={locale}
|
||||
t={td}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel title={td.transfersHeading}>
|
||||
<TransferTable
|
||||
now={new Date()}
|
||||
locale={locale}
|
||||
t={td}
|
||||
transfers={sponsorship.transfers.map((row) => ({
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
expiresAt: row.expiresAt,
|
||||
createdBy: emailOf.get(row.createdById) ?? row.createdById,
|
||||
claimedBy: row.claimedById ? (emailOf.get(row.claimedById) ?? row.claimedById) : null,
|
||||
claimedAt: row.claimedAt,
|
||||
revokedAt: row.revokedAt,
|
||||
}))}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { readCertificatePdf } from "@/lib/sponsorship/storage";
|
||||
import { getAuthorizedAdmin, notFoundResponse } from "../../../_lib/admin-guard";
|
||||
|
||||
/**
|
||||
* Gespeicherte PDF einer Revision (/admin/patenschaften/<id>/revision/3.pdf).
|
||||
* Ausgeliefert wird immer die abgelegte Datei, nie ein Neu-Rendering — sie
|
||||
* ist der Beleg dessen, was die Kundschaft bekommen hat bzw. was gedruckt
|
||||
* wird. ?inline=1 oeffnet sie im Browser (Druck-PDF), sonst Download.
|
||||
* Nur fuer Admins mit 2FA, sonst 404.
|
||||
*/
|
||||
export async function GET(request: NextRequest, ctx: RouteContext<"/admin/patenschaften/[id]/revision/[rev]">): Promise<Response> {
|
||||
if (!(await getAuthorizedAdmin())) return notFoundResponse();
|
||||
const { id, rev } = await ctx.params;
|
||||
const match = /^(\d{1,6})(?:\.pdf)?$/.exec(rev);
|
||||
if (!match) return notFoundResponse();
|
||||
const revision = await prisma.sponsorshipRevision.findUnique({
|
||||
where: { sponsorshipId_revision: { sponsorshipId: id, revision: Number(match[1]) } },
|
||||
select: { revision: true, pdfPath: true, sponsorship: { select: { number: true } } },
|
||||
});
|
||||
if (!revision) return notFoundResponse();
|
||||
const pdf = await readCertificatePdf(revision.pdfPath);
|
||||
if (!pdf) return notFoundResponse();
|
||||
|
||||
const filename = `${revision.sponsorship.number}-r${revision.revision}.pdf`;
|
||||
const disposition = request.nextUrl.searchParams.get("inline") === "1" ? "inline" : "attachment";
|
||||
return new Response(new Uint8Array(pdf), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `${disposition}; filename="${filename}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { renderCertificateDocumentImage } from "@/lib/sponsorship/certificate-document";
|
||||
import { getAuthorizedAdmin, notFoundResponse } from "../../_lib/admin-guard";
|
||||
import { loadRevisionDocument } from "../../_lib/revision-data";
|
||||
|
||||
const PREVIEW_WIDTH_PX = 900;
|
||||
|
||||
/**
|
||||
* Bild der aktuellen Revision fuer die Admin-Detailseite. Gerendert aus den
|
||||
* gespeicherten Revisionsdaten ueber dieselben Helfer wie PDF und Vorschau —
|
||||
* ohne Wasserzeichen, weil es die tatsaechlich ausgestellte Urkunde zeigt.
|
||||
* Nur fuer Admins mit 2FA, sonst 404.
|
||||
*/
|
||||
export async function GET(_request: Request, ctx: RouteContext<"/admin/patenschaften/[id]/vorschau.webp">): Promise<Response> {
|
||||
if (!(await getAuthorizedAdmin())) return notFoundResponse();
|
||||
const { id } = await ctx.params;
|
||||
const document = await loadRevisionDocument(id);
|
||||
if (!document) return notFoundResponse();
|
||||
|
||||
const image = await renderCertificateDocumentImage(document.input, { widthPx: PREVIEW_WIDTH_PX, format: "webp" });
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
// Personenbezogen (Namen, Widmung): nie in geteilten Caches ablegen.
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
||||
import { CERTIFICATE_LIMITS, OCCASIONS, countCharacters, type Occasion } from "@/lib/certificate/schema";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { getSponsorshipAdminStrings, type SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
|
||||
export interface CorrectionValues {
|
||||
beeName: string;
|
||||
recipientName: string;
|
||||
giverNames: string;
|
||||
dedication: string;
|
||||
occasion: Occasion;
|
||||
startDate: string;
|
||||
templateVersion: string;
|
||||
}
|
||||
|
||||
type FieldName = keyof CorrectionValues;
|
||||
|
||||
interface PreviewError {
|
||||
field: FieldName | "layout" | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CorrectionFormProps {
|
||||
sponsorshipId: string;
|
||||
initial: CorrectionValues;
|
||||
isSubscription: boolean;
|
||||
startRange: { min: string; max: string } | null;
|
||||
templateOptions: { value: string; label: string }[];
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
/** Texte werden hier geholt statt als Prop gereicht: sie enthalten Funktionen, die nicht ueber die Server-Client-Grenze gehen. */
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const PREVIEW_DEBOUNCE_MS = 400;
|
||||
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2";
|
||||
|
||||
function errorText(code: string, characters: string[], t: SponsorshipAdminStrings["correction"]): string {
|
||||
if (code === "glyphs") return t.errors.glyphs(characters.join(" "));
|
||||
const known = t.errors[code as Exclude<keyof typeof t.errors, "glyphs">];
|
||||
return typeof known === "string" ? known : t.previewFailed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Korrekturformular mit Live-Vorschau. Die Vorschau kommt vom Server
|
||||
* (/admin/patenschaften/<id>/korrekturvorschau) und nutzt dieselbe
|
||||
* Layoutlogik wie die spaetere PDF. Bis eine neue gueltige Vorschau da ist,
|
||||
* bleibt die letzte sichtbar — gedimmt, damit klar ist, dass sie nicht mehr
|
||||
* dem Formular entspricht.
|
||||
*/
|
||||
export function CorrectionForm({
|
||||
sponsorshipId,
|
||||
initial,
|
||||
isSubscription,
|
||||
startRange,
|
||||
templateOptions,
|
||||
action,
|
||||
locale,
|
||||
}: CorrectionFormProps) {
|
||||
const strings = getSponsorshipAdminStrings(locale);
|
||||
const t = strings.correction;
|
||||
const occasionLabels = strings.occasions;
|
||||
const [values, setValues] = useState<CorrectionValues>(initial);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [isStale, setIsStale] = useState(true);
|
||||
const [error, setError] = useState<PreviewError | null>(null);
|
||||
const urlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
setIsStale(true);
|
||||
try {
|
||||
const response = await fetch(`/admin/patenschaften/${sponsorshipId}/korrekturvorschau`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.status === 422) {
|
||||
const body = (await response.json()) as { field?: PreviewError["field"]; code?: string; characters?: string[] };
|
||||
setError({ field: body.field ?? null, message: errorText(body.code ?? "", body.characters ?? [], t) });
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
|
||||
urlRef.current = url;
|
||||
setPreviewUrl(url);
|
||||
setError(null);
|
||||
setIsStale(false);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") return;
|
||||
setError({ field: null, message: t.previewFailed });
|
||||
}
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [values, sponsorshipId, t]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const update = (field: FieldName) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const value = event.target.value;
|
||||
setValues((previous) => ({ ...previous, [field]: value }));
|
||||
};
|
||||
|
||||
const fieldError = (field: FieldName) =>
|
||||
error?.field === field ? (
|
||||
<span role="alert" className="mt-1 block text-xs text-red-700">
|
||||
{error.message}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const counter = (value: string, max: number) => (
|
||||
<span className={`mt-1 block text-right text-xs tabular-nums ${countCharacters(value) > max ? "text-red-700" : "text-ink/55"}`}>
|
||||
{t.counter(countCharacters(value), max)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const showGivers = values.occasion !== "SELBST";
|
||||
|
||||
return (
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)]">
|
||||
<form action={action} className="space-y-8 border border-ink/10 bg-background p-6 sm:p-8">
|
||||
<input type="hidden" name="id" value={sponsorshipId} />
|
||||
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
||||
<legend className="mb-4 font-serif text-xl text-heading">{t.sectionText}</legend>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.beeName}</span>
|
||||
<input name="beeName" value={values.beeName} onChange={update("beeName")} required className={inputClass} />
|
||||
{counter(values.beeName, CERTIFICATE_LIMITS.beeName.max)}
|
||||
{fieldError("beeName")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.recipientName}</span>
|
||||
<input name="recipientName" value={values.recipientName} onChange={update("recipientName")} required className={inputClass} />
|
||||
{counter(values.recipientName, CERTIFICATE_LIMITS.recipientName.max)}
|
||||
{fieldError("recipientName")}
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.occasion}</span>
|
||||
<select name="occasion" value={values.occasion} onChange={update("occasion")} className={inputClass}>
|
||||
{OCCASIONS.map((occasion) => (
|
||||
<option key={occasion} value={occasion}>
|
||||
{occasionLabels[occasion]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{fieldError("occasion")}
|
||||
</label>
|
||||
{showGivers ? (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.giverNames}</span>
|
||||
<input name="giverNames" value={values.giverNames} onChange={update("giverNames")} className={inputClass} />
|
||||
{counter(values.giverNames, CERTIFICATE_LIMITS.giverNames.max)}
|
||||
{fieldError("giverNames")}
|
||||
</label>
|
||||
) : null}
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.dedication}</span>
|
||||
<textarea name="dedication" value={values.dedication} onChange={update("dedication")} rows={5} className={inputClass} />
|
||||
{counter(values.dedication, CERTIFICATE_LIMITS.dedication.max)}
|
||||
{fieldError("dedication")}
|
||||
</label>
|
||||
{isSubscription || !startRange ? (
|
||||
<div className="text-sm">
|
||||
<span className="text-ink/70">{t.startDate}</span>
|
||||
<p className="mt-1 tabular-nums">{values.startDate}</p>
|
||||
<input type="hidden" name="startDate" value={values.startDate} />
|
||||
<p className="mt-1 text-xs text-ink/65">{t.startDateSubscription}</p>
|
||||
</div>
|
||||
) : (
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.startDate}</span>
|
||||
<input
|
||||
type="date"
|
||||
name="startDate"
|
||||
value={values.startDate}
|
||||
min={startRange.min}
|
||||
max={startRange.max}
|
||||
onChange={update("startDate")}
|
||||
required
|
||||
className={inputClass}
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-ink/65">{t.startDateHint(startRange.min, startRange.max)}</span>
|
||||
{fieldError("startDate")}
|
||||
</label>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
||||
<legend className="mb-4 font-serif text-xl text-heading">{t.sectionTemplate}</legend>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.templateLabel}</span>
|
||||
<select name="templateVersion" value={values.templateVersion} onChange={update("templateVersion")} className={inputClass}>
|
||||
{templateOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-4">
|
||||
<legend className="mb-4 font-serif text-xl text-heading">{t.sectionReason}</legend>
|
||||
<label className="block text-sm">
|
||||
<span className="text-ink/70">{t.reasonLabel}</span>
|
||||
<textarea name="reason" required minLength={3} maxLength={500} rows={3} className={inputClass} />
|
||||
<span className="mt-1 block text-xs text-ink/65">{t.reasonHint}</span>
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
|
||||
<input type="checkbox" name="notifyCustomer" className="h-4 w-4 accent-[var(--color-forest)]" />
|
||||
{t.notifyLabel}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="flex justify-end border-t border-ink/10 pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center justify-center border border-heading bg-forest px-7 py-3 text-sm tracking-wide-caps text-cream transition-colors hover:bg-honey-deep"
|
||||
>
|
||||
{t.submit}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside className="lg:sticky lg:top-6 lg:self-start">
|
||||
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{t.previewHeading}</h2>
|
||||
<div className="relative border border-ink/10 bg-background p-3" aria-live="polite">
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- Blob-URL der Live-Vorschau, next/image kann sie nicht optimieren
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={t.previewAlt}
|
||||
className={`h-auto w-full transition-opacity duration-300 ${isStale ? "opacity-50" : "opacity-100"}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-[595/842] items-center justify-center text-sm text-ink/60">{t.previewLoading}</div>
|
||||
)}
|
||||
{error && (error.field === null || error.field === "layout") ? (
|
||||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||||
{error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
|
||||
interface RevisionRow {
|
||||
revision: number;
|
||||
createdAt: Date;
|
||||
createdByLabel: string;
|
||||
reason: string | null;
|
||||
templateVersion: string;
|
||||
pdfSha256: string;
|
||||
}
|
||||
|
||||
const thClass = "p-3 font-normal";
|
||||
|
||||
/** Alle Revisionen, neueste zuerst; jede mit eigener PDF (alte bleiben abrufbar). */
|
||||
export function RevisionTable({
|
||||
sponsorshipId,
|
||||
revisions,
|
||||
currentRevision,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
sponsorshipId: string;
|
||||
revisions: RevisionRow[];
|
||||
currentRevision: number;
|
||||
locale: Locale;
|
||||
t: SponsorshipAdminStrings["detail"];
|
||||
}) {
|
||||
if (revisions.length === 0) return <p className="text-sm text-ink/65">{t.noRevision}</p>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
|
||||
<tr>
|
||||
<th className={thClass}>{t.colRevision}</th>
|
||||
<th className={thClass}>{t.colCreatedAt}</th>
|
||||
<th className={thClass}>{t.colCreatedBy}</th>
|
||||
<th className={thClass}>{t.colReason}</th>
|
||||
<th className={thClass}>{t.colTemplate}</th>
|
||||
<th className={thClass}>{t.colChecksum}</th>
|
||||
<th className={thClass} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{revisions.map((row) => (
|
||||
<tr key={row.revision}>
|
||||
<td className="whitespace-nowrap p-3 tabular-nums text-heading">
|
||||
r{row.revision}
|
||||
{row.revision === currentRevision ? <span className="ml-2 text-xs text-honey-dark">{t.current}</span> : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-3 tabular-nums text-ink/70">{formatDateTime(row.createdAt, locale)}</td>
|
||||
<td className="p-3">{row.createdByLabel}</td>
|
||||
<td className="max-w-xs p-3 text-ink/80">{row.reason ?? "—"}</td>
|
||||
<td className="whitespace-nowrap p-3 font-mono text-xs">{row.templateVersion}</td>
|
||||
<td className="p-3 font-mono text-xs text-ink/60" title={row.pdfSha256}>
|
||||
{row.pdfSha256.slice(0, 12)}…
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<a href={`/admin/patenschaften/${sponsorshipId}/revision/${row.revision}.pdf`} className="link-draw text-heading">
|
||||
{t.download}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TransferRow {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
createdBy: string;
|
||||
claimedBy: string | null;
|
||||
claimedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Uebertragungshistorie, nur lesend (Uebertragungen steuert die Kundschaft im Konto). */
|
||||
export function TransferTable({ transfers, now, locale, t }: { transfers: TransferRow[]; now: Date; locale: Locale; t: SponsorshipAdminStrings["detail"] }) {
|
||||
if (transfers.length === 0) return <p className="text-sm text-ink/65">{t.noTransfers}</p>;
|
||||
const statusOf = (row: TransferRow): string => {
|
||||
if (row.claimedAt) return t.transferClaimed(row.claimedBy ?? "—", formatDateTime(row.claimedAt, locale));
|
||||
if (row.revokedAt) return t.transferRevoked(formatDateTime(row.revokedAt, locale));
|
||||
return row.expiresAt < now ? t.transferExpired : t.transferOpen;
|
||||
};
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
|
||||
<tr>
|
||||
<th className={thClass}>{t.colTransferCreated}</th>
|
||||
<th className={thClass}>{t.colTransferBy}</th>
|
||||
<th className={thClass}>{t.colExpires}</th>
|
||||
<th className={thClass}>{t.colTransferStatus}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{transfers.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="whitespace-nowrap p-3 tabular-nums text-ink/70">{formatDateTime(row.createdAt, locale)}</td>
|
||||
<td className="p-3">{row.createdBy}</td>
|
||||
<td className="whitespace-nowrap p-3 tabular-nums text-ink/70">{formatDateTime(row.expiresAt, locale)}</td>
|
||||
<td className="p-3">{statusOf(row)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
AdminField,
|
||||
AdminFieldset,
|
||||
AdminFormPanel,
|
||||
AdminHint,
|
||||
AdminSelect,
|
||||
AdminSwitch,
|
||||
AdminTextarea,
|
||||
} from "@/components/admin-form";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import type { MedalTier, SponsorshipCycle, SponsorshipOfferKind } from "@/generated/prisma/client";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { MEDAL_TIERS, OFFER_CYCLES, OFFER_KINDS, centsToEuroInput } from "../_lib/offer-schema";
|
||||
|
||||
export interface OfferFormDefaults {
|
||||
id?: string;
|
||||
kind: SponsorshipOfferKind;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
descriptionEn: string;
|
||||
features: string[];
|
||||
featuresEn: string[];
|
||||
priceCents: number;
|
||||
honeyShareCentsPerJar: number;
|
||||
honeyShareCentsPerMonth: number;
|
||||
isActive: boolean;
|
||||
isPhysical: boolean;
|
||||
sortOrder: number;
|
||||
honeyProductId: string | null;
|
||||
honeyJars: number;
|
||||
cycle: SponsorshipCycle | null;
|
||||
maxActiveSubscriptions: number | null;
|
||||
medalTier: MedalTier;
|
||||
}
|
||||
|
||||
export const EMPTY_OFFER: OfferFormDefaults = {
|
||||
kind: "EINMALIG",
|
||||
name: "",
|
||||
nameEn: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
descriptionEn: "",
|
||||
features: [],
|
||||
featuresEn: [],
|
||||
priceCents: 0,
|
||||
honeyShareCentsPerJar: 0,
|
||||
honeyShareCentsPerMonth: 0,
|
||||
isActive: false,
|
||||
isPhysical: false,
|
||||
sortOrder: 0,
|
||||
honeyProductId: null,
|
||||
honeyJars: 0,
|
||||
cycle: null,
|
||||
maxActiveSubscriptions: null,
|
||||
medalTier: "BRONZE",
|
||||
};
|
||||
|
||||
interface OfferFormProps {
|
||||
defaults: OfferFormDefaults;
|
||||
/** Beim Bearbeiten ist die Art fest. */
|
||||
isNew: boolean;
|
||||
honeyOptions: { id: string; name: string; isActive: boolean }[];
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
t: SponsorshipAdminStrings;
|
||||
}
|
||||
|
||||
const euroOrEmpty = (cents: number): string => (cents > 0 ? centsToEuroInput(cents) : "");
|
||||
|
||||
/**
|
||||
* Ein Formular fuer Einmal- und Abo-Angebote. Felder der jeweils anderen Art
|
||||
* stehen mit "(nur Einmalig)" bzw. "(nur Abo)" da und werden beim Speichern
|
||||
* geleert (offer-schema.ts) — so bleibt das Formular ohne Client-Logik.
|
||||
*/
|
||||
export function OfferForm({ defaults, isNew, honeyOptions, action, t }: OfferFormProps) {
|
||||
const tf = t.offerForm;
|
||||
return (
|
||||
<AdminFormPanel action={action} actions={<Button type="submit">{isNew ? tf.create : tf.save}</Button>}>
|
||||
{defaults.id ? <input type="hidden" name="id" value={defaults.id} /> : null}
|
||||
<AdminFieldset legend={tf.sectionBasics}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminField name="name" label={tf.name} defaultValue={defaults.name} required />
|
||||
<AdminField name="nameEn" label={tf.nameEn} defaultValue={defaults.nameEn} />
|
||||
</div>
|
||||
<AdminField name="slug" label={tf.slug} defaultValue={defaults.slug} required hint={tf.slugHint} />
|
||||
{isNew ? (
|
||||
<AdminSelect name="kind" label={tf.kind} defaultValue={defaults.kind} hint={tf.kindFixed}>
|
||||
{OFFER_KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{t.kinds[kind]}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
) : (
|
||||
<AdminField name="kindDisplay" label={tf.kind} defaultValue={t.kinds[defaults.kind]} readOnly hint={tf.kindFixed} />
|
||||
)}
|
||||
</AdminFieldset>
|
||||
|
||||
<AdminFieldset legend={tf.sectionTexts}>
|
||||
<AdminTextarea name="description" label={tf.description} defaultValue={defaults.description} required rows={3} />
|
||||
<AdminTextarea name="descriptionEn" label={tf.descriptionEn} defaultValue={defaults.descriptionEn} rows={3} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminTextarea name="features" label={tf.features} defaultValue={defaults.features.join("\n")} rows={5} />
|
||||
<AdminTextarea name="featuresEn" label={tf.featuresEn} defaultValue={defaults.featuresEn.join("\n")} rows={5} />
|
||||
</div>
|
||||
</AdminFieldset>
|
||||
|
||||
<AdminFieldset legend={tf.sectionPrice}>
|
||||
<AdminField name="price" label={tf.price} defaultValue={euroOrEmpty(defaults.priceCents)} placeholder="39,00" required />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminField name="honeySharePerJar" label={tf.shareJar} defaultValue={euroOrEmpty(defaults.honeyShareCentsPerJar)} placeholder="7,00" />
|
||||
<AdminField name="honeySharePerMonth" label={tf.shareMonth} defaultValue={euroOrEmpty(defaults.honeyShareCentsPerMonth)} placeholder="4,00" />
|
||||
</div>
|
||||
{!isNew && defaults.kind === "EINMALIG" ? (
|
||||
<AdminHint>
|
||||
{tf.carrierPrice(formatPrice(defaults.priceCents - defaults.honeyJars * defaults.honeyShareCentsPerJar))}
|
||||
</AdminHint>
|
||||
) : null}
|
||||
</AdminFieldset>
|
||||
|
||||
<AdminFieldset legend={tf.sectionHoney}>
|
||||
<AdminSwitch name="isPhysical" label={tf.isPhysical} defaultChecked={defaults.isPhysical} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminSelect name="honeyProductId" label={tf.honeyProduct} defaultValue={defaults.honeyProductId ?? ""}>
|
||||
<option value="">{tf.honeyProductNone}</option>
|
||||
{honeyOptions.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
{product.isActive ? "" : tf.inactiveSuffix}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
<AdminField name="honeyJars" label={tf.honeyJars} type="number" min={0} defaultValue={String(defaults.honeyJars)} />
|
||||
</div>
|
||||
</AdminFieldset>
|
||||
|
||||
<AdminFieldset legend={tf.sectionAbo}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminSelect name="cycle" label={tf.cycle} defaultValue={defaults.cycle ?? ""}>
|
||||
<option value="">{tf.cycleNone}</option>
|
||||
{OFFER_CYCLES.map((cycle) => (
|
||||
<option key={cycle} value={cycle}>
|
||||
{t.cycles[cycle]}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
<AdminField
|
||||
name="maxActiveSubscriptions"
|
||||
label={tf.maxActive}
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={defaults.maxActiveSubscriptions !== null ? String(defaults.maxActiveSubscriptions) : ""}
|
||||
/>
|
||||
</div>
|
||||
<AdminHint>{tf.stripeHint}</AdminHint>
|
||||
</AdminFieldset>
|
||||
|
||||
<AdminFieldset legend={tf.sectionDisplay} last>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<AdminSelect name="medalTier" label={tf.medalTier} defaultValue={defaults.medalTier}>
|
||||
{MEDAL_TIERS.map((tier) => (
|
||||
<option key={tier} value={tier}>
|
||||
{t.medals[tier]}
|
||||
</option>
|
||||
))}
|
||||
</AdminSelect>
|
||||
<AdminField name="sortOrder" label={tf.sortOrder} type="number" min={0} defaultValue={String(defaults.sortOrder)} />
|
||||
</div>
|
||||
<AdminSwitch name="isActive" label={tf.isActive} defaultChecked={defaults.isActive} />
|
||||
</AdminFieldset>
|
||||
</AdminFormPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
hrefFor: (page: number) => string;
|
||||
labels: { previous: string; next: string; pageOf: (page: number, total: number) => string };
|
||||
}
|
||||
|
||||
/** Blaettern unter einer Admin-Liste; erscheint erst ab der zweiten Seite. */
|
||||
export function Pagination({ page, totalPages, hrefFor, labels }: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
const linkClass = "link-draw text-heading";
|
||||
return (
|
||||
<nav aria-label={labels.pageOf(page, totalPages)} className="mt-4 flex items-center justify-between gap-4 text-sm">
|
||||
{page > 1 ? (
|
||||
<Link href={hrefFor(page - 1)} className={linkClass} rel="prev">
|
||||
{labels.previous}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<span className="tabular-nums text-ink/65">{labels.pageOf(page, totalPages)}</span>
|
||||
{page < totalPages ? (
|
||||
<Link href={hrefFor(page + 1)} className={linkClass} rel="next">
|
||||
{labels.next}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Link from "next/link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { Button } from "@/components/ui";
|
||||
import { AdminActionNote } from "@/components/admin-list";
|
||||
import type { SponsorshipStatus } from "@/generated/prisma/client";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { markSponsorshipPrintedAction, resendSponsorshipMailAction, retrySponsorshipAction } from "@/app/admin/actions";
|
||||
|
||||
interface SponsorshipActionsProps {
|
||||
id: string;
|
||||
status: SponsorshipStatus;
|
||||
currentRevision: number;
|
||||
isPhysical: boolean;
|
||||
printed: boolean;
|
||||
ownerEmail: string;
|
||||
t: SponsorshipAdminStrings["detail"];
|
||||
}
|
||||
|
||||
const buttonClass = "px-4 py-2 text-xs";
|
||||
|
||||
/**
|
||||
* Aktionen einer Patenschaft. Jede Aktion steht nur da, wo sie im aktuellen
|
||||
* Zustand Sinn ergibt; fehlt eine, sagt eine kurze Notiz warum. Die Server
|
||||
* Actions pruefen denselben Zustand noch einmal — die Anzeige ist keine
|
||||
* Sicherheitsgrenze.
|
||||
*/
|
||||
export function SponsorshipActions({ id, status, currentRevision, isPhysical, printed, ownerEmail, t }: SponsorshipActionsProps) {
|
||||
const issued = currentRevision > 0;
|
||||
const delivered = status === "BEREIT" || status === "GEDRUCKT";
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{issued ? (
|
||||
<a
|
||||
href={`/admin/patenschaften/${id}/revision/${currentRevision}.pdf?inline=1`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="inline-flex items-center justify-center border border-heading bg-forest px-4 py-2 text-xs tracking-wide-caps text-cream transition-colors hover:bg-honey-deep"
|
||||
>
|
||||
{t.openPrintPdf}
|
||||
</a>
|
||||
) : null}
|
||||
{isPhysical && status === "BEREIT" && !printed ? (
|
||||
<ConfirmForm action={markSponsorshipPrintedAction} confirmMessage={t.markPrintedConfirm}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Button type="submit" variant="secondary" className={buttonClass}>
|
||||
{t.markPrinted}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
{status === "FEHLER" || status === "AUSSTEHEND" ? (
|
||||
<ConfirmForm action={retrySponsorshipAction} confirmMessage={t.retryConfirm}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Button type="submit" variant="secondary" className={buttonClass}>
|
||||
{status === "FEHLER" ? t.retry : t.retryNow}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
{delivered && issued ? (
|
||||
<ConfirmForm action={resendSponsorshipMailAction} confirmMessage={t.resendConfirm(ownerEmail)}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
<Button type="submit" variant="ghost" className={buttonClass}>
|
||||
{t.resendMail}
|
||||
</Button>
|
||||
</ConfirmForm>
|
||||
) : null}
|
||||
{issued ? (
|
||||
<Link
|
||||
href={`/admin/patenschaften/${id}/korrigieren`}
|
||||
className="inline-flex items-center justify-center border border-ink/20 px-4 py-2 text-xs tracking-wide-caps text-ink transition-colors hover:border-ink"
|
||||
>
|
||||
{t.correct}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{!isPhysical ? <AdminActionNote>{t.notePrintPhysical}</AdminActionNote> : null}
|
||||
{isPhysical && !delivered ? <AdminActionNote>{t.notePrintReady}</AdminActionNote> : null}
|
||||
{!delivered ? <AdminActionNote>{t.noteMailReady}</AdminActionNote> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
|
||||
export type SponsorshipSection = "overview" | "offers" | "subscriptions" | "loyalty";
|
||||
|
||||
const SECTIONS: { key: SponsorshipSection; href: string }[] = [
|
||||
{ key: "overview", href: "/admin/patenschaften" },
|
||||
{ key: "offers", href: "/admin/patenschaften/angebote" },
|
||||
{ key: "subscriptions", href: "/admin/patenschaften/abos" },
|
||||
{ key: "loyalty", href: "/admin/patenschaften/treuestufen" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Unternavigation des Bereichs. Die Seitenleiste fuehrt mit einem Eintrag
|
||||
* "Patenschaften" hierher; die vier Teile liegen als Reiter darunter — so
|
||||
* bleibt die Seitenleiste kurz, und es gibt dort nie zwei aktive Eintraege.
|
||||
*/
|
||||
export function SponsorshipTabs({ active, labels }: { active: SponsorshipSection; labels: SponsorshipAdminStrings["tabs"] }) {
|
||||
return (
|
||||
<nav aria-label={labels.ariaLabel} className="mb-6 flex gap-1 overflow-x-auto border-b border-ink/10">
|
||||
{SECTIONS.map((section) => {
|
||||
const selected = section.key === active;
|
||||
return (
|
||||
<Link
|
||||
key={section.key}
|
||||
href={section.href}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
className={`-mb-px whitespace-nowrap border-b-2 px-4 py-2 text-sm transition-colors ${
|
||||
selected ? "border-heading text-heading" : "border-transparent text-ink/60 hover:text-heading"
|
||||
}`}
|
||||
>
|
||||
{labels[section.key]}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AdminBadge, type AdminBadgeTone } from "@/components/admin-list";
|
||||
import type { SponsorshipStatus, SubscriptionStatus } from "@/generated/prisma/client";
|
||||
|
||||
const SPONSORSHIP_TONES: Record<SponsorshipStatus, AdminBadgeTone> = {
|
||||
AUSSTEHEND: "pending",
|
||||
FEHLER: "danger",
|
||||
BEREIT: "positive",
|
||||
GEDRUCKT: "neutral",
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_TONES: Record<SubscriptionStatus, AdminBadgeTone> = {
|
||||
AUSSTEHEND: "neutral",
|
||||
AKTIV: "positive",
|
||||
IM_VERZUG: "danger",
|
||||
GEKUENDIGT: "pending",
|
||||
BEENDET: "neutral",
|
||||
};
|
||||
|
||||
export function SponsorshipStatusBadge({ status, label }: { status: SponsorshipStatus; label: string }) {
|
||||
return (
|
||||
<AdminBadge tone={SPONSORSHIP_TONES[status]} pulse={status === "FEHLER"}>
|
||||
{label}
|
||||
</AdminBadge>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubscriptionStatusBadge({ status, label }: { status: SubscriptionStatus; label: string }) {
|
||||
return <AdminBadge tone={SUBSCRIPTION_TONES[status]}>{label}</AdminBadge>;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { AdminActionButton, AdminBadge } from "@/components/admin-list";
|
||||
import type { SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { liftSepaBlockAction, updateSepaHoldDaysAction } from "@/app/admin/actions";
|
||||
import { MAX_SEPA_HOLD_DAYS } from "@/lib/sponsorship/settings";
|
||||
import type { HoneyCoverageRow } from "../_lib/coverage";
|
||||
|
||||
type Strings = SponsorshipAdminStrings["subscriptions"];
|
||||
|
||||
export function SepaBlockButton({ customerId, confirmMessage, label }: { customerId: string; confirmMessage: string; label: string }) {
|
||||
return (
|
||||
<ConfirmForm action={liftSepaBlockAction} confirmMessage={confirmMessage}>
|
||||
<input type="hidden" name="customerId" value={customerId} />
|
||||
<AdminActionButton>{label}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
);
|
||||
}
|
||||
|
||||
/** Honigdeckung je Produkt; unter einer Lieferrunde rot markiert (auch in der Aufmerksamkeitsliste). */
|
||||
export function CoveragePanel({ rows, t }: { rows: HoneyCoverageRow[]; t: Strings }) {
|
||||
return (
|
||||
<section id="deckung" className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-2 font-serif text-xl text-heading">{t.coverageHeading}</h2>
|
||||
<p className="mb-4 text-xs text-ink/65">{t.coverageIntro}</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{t.noCoverage}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-xs text-ink/65">
|
||||
<tr>
|
||||
<th className="p-3 font-normal">{t.colProduct}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colStock}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colReserved}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colFree}</th>
|
||||
<th className="p-3 text-right font-normal">{t.colPerDelivery}</th>
|
||||
<th className="p-3 font-normal">{t.colCovers}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.productId}>
|
||||
<td className="p-3">{row.productName}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.stock}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.reserved}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.free}</td>
|
||||
<td className="p-3 text-right tabular-nums">{row.jarsPerDelivery}</td>
|
||||
<td className="p-3">
|
||||
{row.deliveriesCovered === null ? (
|
||||
"—"
|
||||
) : row.deliveriesCovered < 1 ? (
|
||||
<AdminBadge tone="danger" pulse>
|
||||
{t.coverageWarning}
|
||||
</AdminBadge>
|
||||
) : (
|
||||
<span className="tabular-nums">{t.covers(row.deliveriesCovered)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SepaSettingsPanel({ holdDays, t }: { holdDays: number; t: Strings }) {
|
||||
return (
|
||||
<section className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-2 font-serif text-xl text-heading">{t.sepaHeading}</h2>
|
||||
<p className="mb-4 text-xs text-ink/65">{t.sepaIntro}</p>
|
||||
<form action={updateSepaHoldDaysAction} className="flex flex-wrap items-end gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.sepaLabel}</span>
|
||||
<input
|
||||
type="number"
|
||||
name="sepaHoldDays"
|
||||
min={0}
|
||||
max={MAX_SEPA_HOLD_DAYS}
|
||||
defaultValue={String(holdDays)}
|
||||
required
|
||||
className="mt-1 w-28 border border-ink/20 bg-background px-3 py-2 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream">
|
||||
{t.sepaSave}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-3 text-xs text-ink/65">{t.sepaCurrent(holdDays)}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getCurrentAdmin } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Pruefung fuer die Route Handler unter /admin/patenschaften (Vorschau,
|
||||
* Revisions-PDFs). Route Handler laufen nicht durch das Admin-Layout, also
|
||||
* gilt hier dieselbe Regel wie in requireAdmin(): Admin-Rolle laut Datenbank
|
||||
* UND aktive Zwei-Faktor-Authentifizierung. Die Aufrufer antworten bei null
|
||||
* mit 404, damit von aussen nicht einmal die Existenz einer Patenschaft
|
||||
* erkennbar ist.
|
||||
*/
|
||||
export async function getAuthorizedAdmin(): Promise<{ id: string; email: string } | null> {
|
||||
const admin = await getCurrentAdmin();
|
||||
if (!admin || !admin.twoFactorEnabled) return null;
|
||||
return { id: admin.id, email: admin.email };
|
||||
}
|
||||
|
||||
export function notFoundResponse(): Response {
|
||||
return new Response("Nicht gefunden.", { status: 404, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { countUndercovered, getHoneyCoverage } from "./coverage";
|
||||
import { TO_PRINT_FILTER } from "./sponsorship-filters";
|
||||
|
||||
export interface AttentionItem {
|
||||
count: number;
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Ab wann eine druckbereite Urkunde als liegengeblieben gilt. */
|
||||
const PRINT_GRACE_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Eintraege der Patenschaft fuer die Aufmerksamkeitsliste im Dashboard:
|
||||
* Ausstellung endgueltig gescheitert, Urkunden, die seit ueber zwei Tagen
|
||||
* auf den Druck warten, Honig, der keine Abo-Lieferung mehr deckt, und Abos
|
||||
* im Verzug. Nur Eintraege mit count > 0.
|
||||
*/
|
||||
export async function getSponsorshipAttentionItems(locale: Locale, now: Date = new Date()): Promise<AttentionItem[]> {
|
||||
const t = getSponsorshipAdminStrings(locale).attention;
|
||||
const [failed, overdueToPrint, delinquent, coverage] = await Promise.all([
|
||||
prisma.sponsorship.count({ where: { status: "FEHLER" } }),
|
||||
// mailSentAt ist der Moment, in dem die Patenschaft BEREIT wurde (die
|
||||
// Mail ist der letzte Schritt der Ausstellung).
|
||||
prisma.sponsorship.count({
|
||||
where: { ...TO_PRINT_FILTER, mailSentAt: { lte: new Date(now.getTime() - PRINT_GRACE_MS) } },
|
||||
}),
|
||||
prisma.sponsorshipSubscription.count({ where: { status: "IM_VERZUG" } }),
|
||||
getHoneyCoverage(now),
|
||||
]);
|
||||
const undercovered = countUndercovered(coverage);
|
||||
return [
|
||||
{ count: failed, href: "/admin/patenschaften?status=FEHLER", label: t.failed(failed) },
|
||||
{ count: overdueToPrint, href: "/admin/patenschaften?status=ZU_DRUCKEN", label: t.toPrint(overdueToPrint) },
|
||||
{ count: undercovered, href: "/admin/patenschaften/abos#deckung", label: t.coverage(undercovered) },
|
||||
{ count: delinquent, href: "/admin/patenschaften/abos?status=IM_VERZUG", label: t.delinquent(delinquent) },
|
||||
].filter((item) => item.count > 0);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { reservedJarsForProduct } from "@/lib/sponsorship-subscription/reservation";
|
||||
|
||||
/**
|
||||
* Honigbestand und Abo-Deckung fuer den Admin (Spec, "Honigbestand und
|
||||
* Kontingent"). Reserviert wird in lib/sponsorship-subscription/reservation.ts
|
||||
* berechnet; hier wird nur zusammengestellt und angezeigt.
|
||||
*/
|
||||
|
||||
/** Abos, deren naechste Lieferung noch Honig braucht (wie in reservation.ts). */
|
||||
const DEMANDING_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
|
||||
export interface HoneyStock {
|
||||
stock: number;
|
||||
reserved: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
export interface HoneyCoverageRow extends HoneyStock {
|
||||
productId: string;
|
||||
productName: string;
|
||||
isActive: boolean;
|
||||
/** Glaeser, die eine vierteljaehrliche Lieferrunde aller laufenden Abos braucht. */
|
||||
jarsPerDelivery: number;
|
||||
/** Wie viele Lieferrunden der Bestand bedient; null = kein vierteljaehrlicher Bedarf. */
|
||||
deliveriesCovered: number | null;
|
||||
}
|
||||
|
||||
/** Ganze Lieferrunden, die ein Bestand deckt; null, wenn es keinen Bedarf gibt. */
|
||||
export function deliveriesCovered(stock: number, jarsPerDelivery: number): number | null {
|
||||
if (jarsPerDelivery <= 0) return null;
|
||||
return Math.floor(Math.max(0, stock) / jarsPerDelivery);
|
||||
}
|
||||
|
||||
/** Bestand, Abo-Reservierung und freier Bestand je Produkt. */
|
||||
export async function getHoneyStock(productIds: string[], now: Date = new Date()): Promise<Map<string, HoneyStock>> {
|
||||
const ids = [...new Set(productIds)];
|
||||
if (ids.length === 0) return new Map();
|
||||
const inventories = await prisma.inventory.findMany({
|
||||
where: { productId: { in: ids } },
|
||||
select: { productId: true, quantity: true },
|
||||
});
|
||||
const stockById = new Map(inventories.map((row) => [row.productId, row.quantity]));
|
||||
const entries = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const stock = stockById.get(id) ?? 0;
|
||||
const reserved = await reservedJarsForProduct(id, now);
|
||||
return [id, { stock, reserved, free: Math.max(0, stock - reserved) }] as const;
|
||||
})
|
||||
);
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
/** Deckung je Honigprodukt, das mit mindestens einem Abo-Angebot verknuepft ist. */
|
||||
export async function getHoneyCoverage(now: Date = new Date()): Promise<HoneyCoverageRow[]> {
|
||||
const offers = await prisma.sponsorshipOffer.findMany({
|
||||
where: { kind: "ABO", honeyProductId: { not: null } },
|
||||
select: { honeyProduct: { select: { id: true, name: true, isActive: true } } },
|
||||
});
|
||||
const products = new Map(
|
||||
offers.flatMap((offer) => (offer.honeyProduct ? [[offer.honeyProduct.id, offer.honeyProduct] as const] : []))
|
||||
);
|
||||
if (products.size === 0) return [];
|
||||
|
||||
const [stock, subscriptions] = await Promise.all([
|
||||
getHoneyStock([...products.keys()], now),
|
||||
prisma.sponsorshipSubscription.findMany({
|
||||
where: {
|
||||
status: { in: [...DEMANDING_STATUSES] },
|
||||
offer: { cycle: "VIERTELJAEHRLICH", honeyProductId: { in: [...products.keys()] } },
|
||||
},
|
||||
select: { offer: { select: { honeyProductId: true, honeyJars: true } } },
|
||||
}),
|
||||
]);
|
||||
const demand = new Map<string, number>();
|
||||
for (const { offer } of subscriptions) {
|
||||
if (!offer.honeyProductId) continue;
|
||||
demand.set(offer.honeyProductId, (demand.get(offer.honeyProductId) ?? 0) + offer.honeyJars);
|
||||
}
|
||||
|
||||
return [...products.values()]
|
||||
.map((product) => {
|
||||
const amounts = stock.get(product.id) ?? { stock: 0, reserved: 0, free: 0 };
|
||||
const jarsPerDelivery = demand.get(product.id) ?? 0;
|
||||
return {
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
isActive: product.isActive,
|
||||
...amounts,
|
||||
jarsPerDelivery,
|
||||
deliveriesCovered: deliveriesCovered(amounts.stock, jarsPerDelivery),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.productName.localeCompare(b.productName, "de"));
|
||||
}
|
||||
|
||||
/** Fuer die Aufmerksamkeitsliste: Produkte, deren Bestand keine volle Lieferrunde mehr deckt. */
|
||||
export function countUndercovered(rows: HoneyCoverageRow[]): number {
|
||||
return rows.filter((row) => row.deliveriesCovered !== null && row.deliveriesCovered < 1).length;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isOfferAvailable } from "@/lib/sponsorship/offers";
|
||||
import { getHoneyStock, type HoneyStock } from "./coverage";
|
||||
|
||||
/** Abos, die einen Platz im Kontingent belegen (wie lib/sponsorship/offers.ts). */
|
||||
const QUOTA_STATUSES = ["AKTIV", "IM_VERZUG", "GEKUENDIGT"] as const;
|
||||
|
||||
async function loadOffers(where: { id?: string }) {
|
||||
return prisma.sponsorshipOffer.findMany({
|
||||
where,
|
||||
include: {
|
||||
honeyProduct: { select: { id: true, name: true, isActive: true } },
|
||||
product: { select: { id: true, priceCents: true } },
|
||||
},
|
||||
orderBy: [{ kind: "asc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export type OfferRow = Awaited<ReturnType<typeof loadOffers>>[number];
|
||||
|
||||
export interface OfferOverviewRow {
|
||||
offer: OfferRow;
|
||||
/** Live: dieselbe Entscheidung wie auf der Verkaufsseite. */
|
||||
isAvailable: boolean;
|
||||
activeSubscriptions: number;
|
||||
honeyStock: HoneyStock | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angebote samt Live-Verfuegbarkeit und Honigbestand. Fuer ALLE Angebote,
|
||||
* auch inaktive — getActiveOffers() aus lib/sponsorship/offers.ts kennt nur
|
||||
* aktive, der Admin will aber sehen, ob ein Angebot nach dem Aktivieren
|
||||
* ueberhaupt bestellbar waere. Die Entscheidung selbst bleibt isOfferAvailable.
|
||||
*/
|
||||
export async function getOfferOverview(offerId?: string): Promise<OfferOverviewRow[]> {
|
||||
const offers = await loadOffers(offerId ? { id: offerId } : {});
|
||||
const honeyIds = offers.flatMap((offer) => (offer.honeyProductId ? [offer.honeyProductId] : []));
|
||||
const [stock, groups] = await Promise.all([
|
||||
getHoneyStock(honeyIds),
|
||||
prisma.sponsorshipSubscription.groupBy({
|
||||
by: ["offerId"],
|
||||
where: { offerId: { in: offers.map((offer) => offer.id) }, status: { in: [...QUOTA_STATUSES] } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const subscriptionCounts = new Map(groups.map((group) => [group.offerId, group._count._all]));
|
||||
|
||||
return offers.map((offer) => {
|
||||
const honeyStock = offer.honeyProductId ? (stock.get(offer.honeyProductId) ?? null) : null;
|
||||
const activeSubscriptions = subscriptionCounts.get(offer.id) ?? 0;
|
||||
return {
|
||||
offer,
|
||||
activeSubscriptions,
|
||||
honeyStock,
|
||||
isAvailable: isOfferAvailable({
|
||||
isActive: offer.isActive,
|
||||
kind: offer.kind,
|
||||
hasCarrierProduct: offer.productId !== null,
|
||||
honeyJars: offer.honeyJars,
|
||||
honeyProductActive: offer.honeyProduct ? offer.honeyProduct.isActive : null,
|
||||
freeJars: honeyStock?.free ?? 0,
|
||||
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||
activeSubscriptions,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Auswahl im Formular: aktive Honigprodukte, plus das aktuell verknuepfte, falls es inzwischen inaktiv ist. */
|
||||
export async function getHoneyProductOptions(currentId: string | null): Promise<{ id: string; name: string; isActive: boolean }[]> {
|
||||
return prisma.product.findMany({
|
||||
where: {
|
||||
category: "HONIG",
|
||||
OR: [{ isActive: true }, ...(currentId ? [{ id: currentId }] : [])],
|
||||
},
|
||||
select: { id: true, name: true, isActive: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { carrierPriceCents, centsToEuroInput, parseEuroToCents, parseOfferForm } from "./offer-schema";
|
||||
|
||||
function form(values: Record<string, string>): FormData {
|
||||
const data = new FormData();
|
||||
for (const [key, value] of Object.entries(values)) data.set(key, value);
|
||||
return data;
|
||||
}
|
||||
|
||||
const oneTime = {
|
||||
kind: "EINMALIG",
|
||||
name: "Honigfreund",
|
||||
slug: "honigfreund",
|
||||
description: "Gedruckte Urkunde und ein Glas Honig.",
|
||||
features: "Gedruckte Urkunde\n\n1 × 500 g Honig\n",
|
||||
price: "39,00",
|
||||
honeySharePerJar: "7",
|
||||
isPhysical: "on",
|
||||
honeyProductId: "prod_1",
|
||||
honeyJars: "1",
|
||||
medalTier: "SILBER",
|
||||
};
|
||||
|
||||
const subscription = {
|
||||
kind: "ABO",
|
||||
name: "Abo vierteljährlich",
|
||||
slug: "abo-vierteljaehrlich",
|
||||
description: "Monatlich bezahlt, Honig nach drei Monaten.",
|
||||
price: "12,50",
|
||||
honeySharePerMonth: "4",
|
||||
isPhysical: "on",
|
||||
honeyProductId: "prod_1",
|
||||
honeyJars: "2",
|
||||
cycle: "VIERTELJAEHRLICH",
|
||||
medalTier: "ABO",
|
||||
};
|
||||
|
||||
describe("parseEuroToCents", () => {
|
||||
it("versteht deutsche und englische Schreibweisen", () => {
|
||||
expect(parseEuroToCents("19")).toBe(1900);
|
||||
expect(parseEuroToCents("19,9")).toBe(1990);
|
||||
expect(parseEuroToCents("19.90 €")).toBe(1990);
|
||||
expect(parseEuroToCents("1.234,56")).toBe(123456);
|
||||
});
|
||||
|
||||
it("lehnt Unklares ab", () => {
|
||||
expect(parseEuroToCents("")).toBeNull();
|
||||
expect(parseEuroToCents("19,999")).toBeNull();
|
||||
expect(parseEuroToCents("-5")).toBeNull();
|
||||
expect(parseEuroToCents("abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("wandelt Cent zurueck ins Formularformat", () => {
|
||||
expect(centsToEuroInput(1990)).toBe("19,90");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOfferForm", () => {
|
||||
it("liest ein gueltiges Einmal-Angebot und raeumt Abo-Felder ab", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, cycle: "ERNTE", honeySharePerMonth: "3" }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toMatchObject({
|
||||
kind: "EINMALIG",
|
||||
priceCents: 3900,
|
||||
honeyShareCentsPerJar: 700,
|
||||
honeyShareCentsPerMonth: 0,
|
||||
cycle: null,
|
||||
features: ["Gedruckte Urkunde", "1 × 500 g Honig"],
|
||||
nameEn: null,
|
||||
isActive: false,
|
||||
});
|
||||
expect(carrierPriceCents(result.value)).toBe(3200);
|
||||
});
|
||||
|
||||
it("verlangt Honiganteil × Gläser kleiner als den Paketpreis", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyJars: "3", honeySharePerJar: "13" }));
|
||||
expect(result).toEqual({ ok: false, message: "Honiganteil × Gläser muss kleiner als der Paketpreis sein." });
|
||||
});
|
||||
|
||||
it("verlangt bei Glaesern im Paket einen Honiganteil", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeySharePerJar: "" }));
|
||||
expect(result).toEqual({ ok: false, message: "Bei Gläsern im Paket bitte den Honiganteil je Glas angeben." });
|
||||
});
|
||||
|
||||
it("verlangt fuer physische Angebote Honigprodukt und Glas", () => {
|
||||
const result = parseOfferForm(form({ ...oneTime, honeyProductId: "", honeyJars: "0", honeySharePerJar: "" }));
|
||||
expect(result).toEqual({ ok: false, message: "Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas." });
|
||||
});
|
||||
|
||||
it("erlaubt ein rein digitales Angebot ohne Honig", () => {
|
||||
const result = parseOfferForm(
|
||||
form({ ...oneTime, isPhysical: "", honeyProductId: "", honeyJars: "0", honeySharePerJar: "", price: "19" })
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(carrierPriceCents(result.value)).toBe(1900);
|
||||
});
|
||||
|
||||
it("verlangt beim Abo einen Zyklus und einen Monatsanteil unter dem Monatspreis", () => {
|
||||
expect(parseOfferForm(form({ ...subscription, cycle: "" }))).toEqual({
|
||||
ok: false,
|
||||
message: "Ein Abo braucht einen Lieferzyklus.",
|
||||
});
|
||||
expect(parseOfferForm(form({ ...subscription, honeySharePerMonth: "12,50" }))).toEqual({
|
||||
ok: false,
|
||||
message: "Der Honiganteil je Monat muss kleiner als der Monatspreis sein.",
|
||||
});
|
||||
});
|
||||
|
||||
it("liest ein Abo mit Kontingent und leert den Glasanteil", () => {
|
||||
const result = parseOfferForm(form({ ...subscription, maxActiveSubscriptions: "40", honeySharePerJar: "5" }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toMatchObject({ priceCents: 1250, honeyShareCentsPerMonth: 400, honeyShareCentsPerJar: 0, maxActiveSubscriptions: 40 });
|
||||
}
|
||||
});
|
||||
|
||||
it("setzt beim Bearbeiten die gespeicherte Art durch", () => {
|
||||
const result = parseOfferForm(form({ ...subscription, kind: "EINMALIG" }), "ABO");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.kind).toBe("ABO");
|
||||
});
|
||||
|
||||
it("prueft Slug und Preis", () => {
|
||||
expect(parseOfferForm(form({ ...oneTime, slug: "Honig Freund" }))).toEqual({
|
||||
ok: false,
|
||||
message: "Adresse: nur Kleinbuchstaben, Ziffern und einzelne Bindestriche.",
|
||||
});
|
||||
expect(parseOfferForm(form({ ...oneTime, price: "abc" }))).toEqual({
|
||||
ok: false,
|
||||
message: "Preis: bitte einen Betrag in Euro angeben, z. B. 19,90.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { z } from "zod";
|
||||
import type { MedalTier, SponsorshipCycle, SponsorshipOfferKind } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Formularpruefung der Patenschafts-Angebote. Rein (ohne Datenbank), damit
|
||||
* die Geschaeftsregeln — vor allem die Steueraufteilung — testbar bleiben.
|
||||
* Meldungen auf Deutsch, sie erscheinen unveraendert als Flash-Meldung.
|
||||
*/
|
||||
|
||||
export const OFFER_KINDS = ["EINMALIG", "ABO"] as const satisfies readonly SponsorshipOfferKind[];
|
||||
export const OFFER_CYCLES = ["VIERTELJAEHRLICH", "ERNTE"] as const satisfies readonly SponsorshipCycle[];
|
||||
export const MEDAL_TIERS = ["BRONZE", "SILBER", "GOLD", "ABO"] as const satisfies readonly MedalTier[];
|
||||
|
||||
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_PRICE_CENTS = 1_000_000;
|
||||
const MAX_FEATURES = 12;
|
||||
const MAX_FEATURE_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* Euro-Eingabe in Cent: "19", "19,9", "19,90", "19.90" und "1.234,56".
|
||||
* null bei allem anderen — lieber ablehnen als einen Preis raten.
|
||||
*/
|
||||
export function parseEuroToCents(value: string): number | null {
|
||||
let text = value.replace(/[\s€]/g, "");
|
||||
if (text === "") return null;
|
||||
if (text.includes(",") && text.includes(".")) text = text.replace(/\./g, "");
|
||||
text = text.replace(",", ".");
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(text)) return null;
|
||||
return Math.round(Number(text) * 100);
|
||||
}
|
||||
|
||||
/** Cent als Formularwert, z.B. 1990 → "19,90". */
|
||||
export function centsToEuroInput(cents: number): string {
|
||||
return (cents / 100).toFixed(2).replace(".", ",");
|
||||
}
|
||||
|
||||
function euro(label: string, { optional }: { optional: boolean }) {
|
||||
return z.string().transform((value, ctx) => {
|
||||
if (optional && value.trim() === "") return 0;
|
||||
const cents = parseEuroToCents(value);
|
||||
if (cents === null || cents > MAX_PRICE_CENTS) {
|
||||
ctx.addIssue({ code: "custom", message: `${label}: bitte einen Betrag in Euro angeben, z. B. 19,90.` });
|
||||
return z.NEVER;
|
||||
}
|
||||
return cents;
|
||||
});
|
||||
}
|
||||
|
||||
function optionalText(max: number, label: string) {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max, `${label}: höchstens ${max} Zeichen.`)
|
||||
.transform((value) => (value === "" ? null : value));
|
||||
}
|
||||
|
||||
/** Eine Leistung je Zeile; leere Zeilen fallen weg. */
|
||||
function featureLines(label: string) {
|
||||
return z
|
||||
.string()
|
||||
.transform((value) => value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))
|
||||
.pipe(
|
||||
z
|
||||
.array(z.string().max(MAX_FEATURE_LENGTH, `${label}: jede Zeile höchstens ${MAX_FEATURE_LENGTH} Zeichen.`))
|
||||
.max(MAX_FEATURES, `${label}: höchstens ${MAX_FEATURES} Zeilen.`)
|
||||
);
|
||||
}
|
||||
|
||||
function intField(label: string, min: number, max: number) {
|
||||
return z.coerce
|
||||
.number({ error: `${label}: bitte eine ganze Zahl angeben.` })
|
||||
.int(`${label}: bitte eine ganze Zahl angeben.`)
|
||||
.min(min, `${label}: mindestens ${min}.`)
|
||||
.max(max, `${label}: höchstens ${max}.`);
|
||||
}
|
||||
|
||||
const offerFormSchema = z
|
||||
.object({
|
||||
kind: z.enum(OFFER_KINDS, { error: "Bitte die Art des Angebots wählen." }),
|
||||
name: z.string().trim().min(2, "Bitte einen Namen angeben.").max(80, "Name: höchstens 80 Zeichen."),
|
||||
nameEn: optionalText(80, "Name (Englisch)"),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(2, "Bitte eine Adresse (Slug) angeben.")
|
||||
.max(60, "Adresse: höchstens 60 Zeichen.")
|
||||
.regex(SLUG, "Adresse: nur Kleinbuchstaben, Ziffern und einzelne Bindestriche."),
|
||||
description: z.string().trim().min(1, "Bitte eine Beschreibung angeben.").max(2000, "Beschreibung: höchstens 2000 Zeichen."),
|
||||
descriptionEn: optionalText(2000, "Beschreibung (Englisch)"),
|
||||
features: featureLines("Leistungen"),
|
||||
featuresEn: featureLines("Leistungen (Englisch)"),
|
||||
priceCents: euro("Preis", { optional: false }),
|
||||
honeyShareCentsPerJar: euro("Honiganteil je Glas", { optional: true }),
|
||||
honeyShareCentsPerMonth: euro("Honiganteil je Monat", { optional: true }),
|
||||
isActive: z.boolean(),
|
||||
isPhysical: z.boolean(),
|
||||
sortOrder: intField("Reihenfolge", 0, 9999),
|
||||
honeyProductId: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(64)
|
||||
.transform((value) => (value === "" ? null : value)),
|
||||
honeyJars: intField("Gläser", 0, 24),
|
||||
cycle: z.union([z.enum(OFFER_CYCLES), z.literal("")]).transform((value) => (value === "" ? null : value)),
|
||||
maxActiveSubscriptions: z.union([z.literal(""), intField("Höchstzahl aktiver Abos", 1, 100_000)]).transform((value) =>
|
||||
value === "" ? null : value
|
||||
),
|
||||
medalTier: z.enum(MEDAL_TIERS, { error: "Bitte eine Medaillenstufe wählen." }),
|
||||
})
|
||||
.superRefine((offer, ctx) => {
|
||||
const issue = (message: string) => ctx.addIssue({ code: "custom", message });
|
||||
if (offer.priceCents <= 0) issue("Der Preis muss größer als 0 € sein.");
|
||||
if (offer.honeyJars > 0 && !offer.honeyProductId) issue("Gläser ohne Honigprodukt: bitte ein Honigprodukt wählen.");
|
||||
if (offer.isPhysical && (!offer.honeyProductId || offer.honeyJars < 1)) {
|
||||
issue("Ein physisches Angebot braucht ein Honigprodukt und mindestens ein Glas.");
|
||||
}
|
||||
if (offer.kind === "EINMALIG") {
|
||||
// Der Rest nach Abzug des Honiganteils ist der Preis des Traegerprodukts
|
||||
// (Urkunde und Leistung zum Regelsatz) — er muss positiv bleiben.
|
||||
if (offer.honeyJars * offer.honeyShareCentsPerJar >= offer.priceCents) {
|
||||
issue("Honiganteil × Gläser muss kleiner als der Paketpreis sein.");
|
||||
}
|
||||
// Die Honigposition im Checkout kostet genau den Honiganteil je Glas;
|
||||
// eine 0-€-Position wuerde den Honig steuerlich verschwinden lassen.
|
||||
if (offer.honeyJars > 0 && offer.honeyShareCentsPerJar <= 0) {
|
||||
issue("Bei Gläsern im Paket bitte den Honiganteil je Glas angeben.");
|
||||
}
|
||||
} else {
|
||||
if (offer.honeyShareCentsPerMonth >= offer.priceCents) issue("Der Honiganteil je Monat muss kleiner als der Monatspreis sein.");
|
||||
if (!offer.cycle) issue("Ein Abo braucht einen Lieferzyklus.");
|
||||
}
|
||||
})
|
||||
// Felder, die zur jeweils anderen Art gehoeren, werden geleert — sonst
|
||||
// blieben z.B. ein Lieferzyklus oder ein Monatsanteil an einem
|
||||
// Einmal-Angebot haengen, die nirgends angezeigt, aber gespeichert waeren.
|
||||
.transform((offer) =>
|
||||
offer.kind === "EINMALIG"
|
||||
? { ...offer, honeyShareCentsPerMonth: 0, cycle: null, maxActiveSubscriptions: null }
|
||||
: { ...offer, honeyShareCentsPerJar: 0 }
|
||||
);
|
||||
|
||||
export type OfferFormValues = z.output<typeof offerFormSchema>;
|
||||
|
||||
function field(formData: FormData, key: string): string {
|
||||
return String(formData.get(key) ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest und prueft das Angebotsformular. `fixedKind` setzt beim Bearbeiten
|
||||
* die gespeicherte Art durch — die Art ist nach dem Anlegen unveraenderlich,
|
||||
* ein manipuliertes Formularfeld darf daran nichts aendern.
|
||||
*/
|
||||
export function parseOfferForm(
|
||||
formData: FormData,
|
||||
fixedKind?: SponsorshipOfferKind
|
||||
): { ok: true; value: OfferFormValues } | { ok: false; message: string } {
|
||||
const parsed = offerFormSchema.safeParse({
|
||||
kind: fixedKind ?? field(formData, "kind"),
|
||||
name: field(formData, "name"),
|
||||
nameEn: field(formData, "nameEn"),
|
||||
slug: field(formData, "slug"),
|
||||
description: field(formData, "description"),
|
||||
descriptionEn: field(formData, "descriptionEn"),
|
||||
features: field(formData, "features"),
|
||||
featuresEn: field(formData, "featuresEn"),
|
||||
priceCents: field(formData, "price"),
|
||||
honeyShareCentsPerJar: field(formData, "honeySharePerJar"),
|
||||
honeyShareCentsPerMonth: field(formData, "honeySharePerMonth"),
|
||||
isActive: formData.get("isActive") === "on",
|
||||
isPhysical: formData.get("isPhysical") === "on",
|
||||
sortOrder: field(formData, "sortOrder") || "0",
|
||||
honeyProductId: field(formData, "honeyProductId"),
|
||||
honeyJars: field(formData, "honeyJars") || "0",
|
||||
cycle: field(formData, "cycle"),
|
||||
maxActiveSubscriptions: field(formData, "maxActiveSubscriptions").trim(),
|
||||
medalTier: field(formData, "medalTier"),
|
||||
});
|
||||
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? "Ungültige Eingabe." };
|
||||
return { ok: true, value: parsed.data };
|
||||
}
|
||||
|
||||
/** Preis des Traegerprodukts einer Einmal-Patenschaft: Paketpreis minus Honiganteil. */
|
||||
export function carrierPriceCents(offer: Pick<OfferFormValues, "priceCents" | "honeyJars" | "honeyShareCentsPerJar">): number {
|
||||
return offer.priceCents - offer.honeyJars * offer.honeyShareCentsPerJar;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { defaultLocale, isLocale } from "@/i18n/config";
|
||||
import type { CertificateTerm } from "@/lib/certificate/schema";
|
||||
import { parseStoredPersonalization, type CertificateDocumentInput } from "@/lib/sponsorship/certificate-document";
|
||||
|
||||
const termSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("FIXED"), start: z.string(), end: z.string() }),
|
||||
z.object({ kind: z.literal("OPEN"), start: z.string() }),
|
||||
z.object({ kind: z.literal("ENDED"), start: z.string(), end: z.string() }),
|
||||
]);
|
||||
|
||||
/** Laufzeit einer gespeicherten Revision; wirft bei einem unerwarteten Format statt still falsch zu rendern. */
|
||||
export function parseStoredTerm(value: unknown): CertificateTerm {
|
||||
return termSchema.parse(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bienenname aus einem gespeicherten Personalisierungs-JSON — entweder direkt
|
||||
* (Revision: { beeName, … }) oder verpackt (Bestellposition/Abo:
|
||||
* { personalization: { beeName, … }, templateVersion }). Nur fuer Anzeige
|
||||
* und Suche, deshalb ohne Schema-Pruefung.
|
||||
*/
|
||||
export function beeNameFromJson(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as { beeName?: unknown; personalization?: unknown };
|
||||
if (typeof record.beeName === "string") return record.beeName;
|
||||
return beeNameFromJson(record.personalization);
|
||||
}
|
||||
|
||||
export interface RevisionDocument {
|
||||
input: CertificateDocumentInput;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Daten einer gespeicherten Revision so, wie die Urkunden-Helfer sie zum
|
||||
* Rendern brauchen. Ohne `revision` die aktuelle. null, wenn es die
|
||||
* Patenschaft oder die Revision nicht gibt.
|
||||
*/
|
||||
export async function loadRevisionDocument(sponsorshipId: string, revision?: number): Promise<RevisionDocument | null> {
|
||||
const sponsorship = await prisma.sponsorship.findUnique({
|
||||
where: { id: sponsorshipId },
|
||||
select: { number: true, locale: true, currentRevision: true },
|
||||
});
|
||||
if (!sponsorship) return null;
|
||||
const wanted = revision ?? sponsorship.currentRevision;
|
||||
if (wanted < 1) return null;
|
||||
const row = await prisma.sponsorshipRevision.findUnique({
|
||||
where: { sponsorshipId_revision: { sponsorshipId, revision: wanted } },
|
||||
select: { personalization: true, term: true, templateVersion: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
const stored = parseStoredPersonalization({ personalization: row.personalization, templateVersion: row.templateVersion });
|
||||
return {
|
||||
revision: wanted,
|
||||
input: {
|
||||
personalization: stored.personalization,
|
||||
templateVersion: stored.templateVersion,
|
||||
term: parseStoredTerm(row.term),
|
||||
number: sponsorship.number,
|
||||
locale: isLocale(sponsorship.locale) ? sponsorship.locale : defaultLocale,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { insensitiveContains } from "@/lib/prisma-search";
|
||||
import type { Prisma, SponsorshipOfferKind } from "@/generated/prisma/client";
|
||||
import { beeNameFromJson } from "./revision-data";
|
||||
|
||||
/**
|
||||
* Filter der Patenschaftsliste. "ZU_DRUCKEN" ist kein gespeicherter Status,
|
||||
* sondern die Arbeitsliste des Druckablaufs: physisches Paket, Urkunde
|
||||
* bereit, noch nicht gedruckt.
|
||||
*/
|
||||
export const LIST_STATUSES = ["AUSSTEHEND", "FEHLER", "BEREIT", "GEDRUCKT", "ZU_DRUCKEN"] as const;
|
||||
export type ListStatus = (typeof LIST_STATUSES)[number];
|
||||
|
||||
export const TO_PRINT_FILTER = {
|
||||
status: "BEREIT",
|
||||
printedAt: null,
|
||||
offer: { isPhysical: true },
|
||||
} satisfies Prisma.SponsorshipWhereInput;
|
||||
|
||||
export const PAGE_SIZE = 50;
|
||||
|
||||
/** Obergrenze der Zeilen, die fuer die Bienennamen-Suche gelesen werden (siehe sponsorshipIdsByBeeName). */
|
||||
const BEE_SEARCH_LIMIT = 5000;
|
||||
|
||||
export interface SponsorshipListFilters {
|
||||
query: string;
|
||||
status: ListStatus | null;
|
||||
kind: SponsorshipOfferKind | null;
|
||||
offerId: string | null;
|
||||
page: number;
|
||||
}
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
function single(params: SearchParams, key: string): string {
|
||||
const value = params[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function parseListFilters(params: SearchParams): SponsorshipListFilters {
|
||||
const status = single(params, "status");
|
||||
const kind = single(params, "art");
|
||||
const page = Number.parseInt(single(params, "seite"), 10);
|
||||
return {
|
||||
query: single(params, "q").slice(0, 100),
|
||||
status: (LIST_STATUSES as readonly string[]).includes(status) ? (status as ListStatus) : null,
|
||||
kind: kind === "EINMALIG" || kind === "ABO" ? kind : null,
|
||||
offerId: single(params, "angebot") || null,
|
||||
page: Number.isFinite(page) && page > 1 ? page : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** URL der Liste mit den aktuellen Filtern; `page` ueberschreibt die Seite. */
|
||||
export function listHref(filters: SponsorshipListFilters, page: number): string {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.query) params.set("q", filters.query);
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.kind) params.set("art", filters.kind);
|
||||
if (filters.offerId) params.set("angebot", filters.offerId);
|
||||
if (page > 1) params.set("seite", String(page));
|
||||
const query = params.toString();
|
||||
return query ? `/admin/patenschaften?${query}` : "/admin/patenschaften";
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Bienenname steht nur im JSON der Personalisierung (Revision,
|
||||
* Bestellposition, Abo). JSON-Pfadfilter sind zwischen Postgres und MySQL
|
||||
* nicht gleich formuliert und im generierten Client verschieden typisiert —
|
||||
* deshalb wird hier im Speicher gesucht. Bei der Groesse einer Imkerei ist
|
||||
* das billig; die Obergrenze verhindert, dass es je unbemerkt teuer wird.
|
||||
* Gefunden werden auch fruehere Namen (vor einer Korrektur).
|
||||
*/
|
||||
async function sponsorshipIdsByBeeName(query: string): Promise<string[]> {
|
||||
const needle = query.toLocaleLowerCase("de");
|
||||
const matches = (value: unknown) => beeNameFromJson(value)?.toLocaleLowerCase("de").includes(needle) ?? false;
|
||||
const notIssued = { sponsorship: { is: { currentRevision: 0 } } };
|
||||
const [revisions, orderItems, subscriptions] = await Promise.all([
|
||||
prisma.sponsorshipRevision.findMany({
|
||||
select: { sponsorshipId: true, personalization: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: BEE_SEARCH_LIMIT,
|
||||
}),
|
||||
prisma.orderItem.findMany({
|
||||
where: notIssued,
|
||||
select: { personalization: true, sponsorship: { select: { id: true } } },
|
||||
take: BEE_SEARCH_LIMIT,
|
||||
}),
|
||||
prisma.sponsorshipSubscription.findMany({
|
||||
where: notIssued,
|
||||
select: { personalization: true, sponsorship: { select: { id: true } } },
|
||||
take: BEE_SEARCH_LIMIT,
|
||||
}),
|
||||
]);
|
||||
const ids = new Set<string>();
|
||||
for (const row of revisions) if (matches(row.personalization)) ids.add(row.sponsorshipId);
|
||||
for (const row of [...orderItems, ...subscriptions]) {
|
||||
if (row.sponsorship && matches(row.personalization)) ids.add(row.sponsorship.id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export async function buildSponsorshipWhere(filters: SponsorshipListFilters): Promise<Prisma.SponsorshipWhereInput> {
|
||||
const and: Prisma.SponsorshipWhereInput[] = [];
|
||||
if (filters.status === "ZU_DRUCKEN") and.push(TO_PRINT_FILTER);
|
||||
else if (filters.status) and.push({ status: filters.status });
|
||||
if (filters.kind) and.push({ offer: { kind: filters.kind } });
|
||||
if (filters.offerId) and.push({ offerId: filters.offerId });
|
||||
if (filters.query) {
|
||||
const beeIds = await sponsorshipIdsByBeeName(filters.query);
|
||||
and.push({
|
||||
OR: [
|
||||
{ number: insensitiveContains(filters.query) },
|
||||
{ owner: { email: insensitiveContains(filters.query) } },
|
||||
...(beeIds.length > 0 ? [{ id: { in: beeIds } }] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
return and.length > 0 ? { AND: and } : {};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
import { nextDeliveryView } from "./subscription-view";
|
||||
import { countUndercovered, deliveriesCovered, type HoneyCoverageRow } from "./coverage";
|
||||
|
||||
describe("nextDeliveryView", () => {
|
||||
it("vierteljaehrlich: zaehlt die fehlenden bezahlten Monate", () => {
|
||||
expect(nextDeliveryView({ cycle: "VIERTELJAEHRLICH", countingPaidMonths: 0, nextWindowStart: null })).toEqual({
|
||||
kind: "quarterly",
|
||||
remaining: 3,
|
||||
});
|
||||
expect(nextDeliveryView({ cycle: "VIERTELJAEHRLICH", countingPaidMonths: 2, nextWindowStart: null })).toEqual({
|
||||
kind: "quarterly",
|
||||
remaining: 1,
|
||||
});
|
||||
expect(nextDeliveryView({ cycle: "VIERTELJAEHRLICH", countingPaidMonths: 5, nextWindowStart: null })).toEqual({
|
||||
kind: "quarterly",
|
||||
remaining: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("Ernte: naechstes Fenster und Fortschritt bis sechs Monate", () => {
|
||||
const start = new Date("2027-07-01T00:00:00Z");
|
||||
expect(nextDeliveryView({ cycle: "ERNTE", countingPaidMonths: 8, nextWindowStart: start })).toEqual({
|
||||
kind: "harvest",
|
||||
windowStart: start,
|
||||
paid: 6,
|
||||
needed: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it("ohne Zyklus keine Angabe", () => {
|
||||
expect(nextDeliveryView({ cycle: null, countingPaidMonths: 4, nextWindowStart: null })).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Honigdeckung", () => {
|
||||
it("rechnet ganze Lieferrunden und ignoriert fehlenden Bedarf", () => {
|
||||
expect(deliveriesCovered(10, 4)).toBe(2);
|
||||
expect(deliveriesCovered(3, 4)).toBe(0);
|
||||
expect(deliveriesCovered(-2, 4)).toBe(0);
|
||||
expect(deliveriesCovered(10, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("zaehlt nur Produkte unter einer Lieferrunde", () => {
|
||||
const row = (deliveries: number | null): HoneyCoverageRow => ({
|
||||
productId: "p",
|
||||
productName: "Honig",
|
||||
isActive: true,
|
||||
stock: 0,
|
||||
reserved: 0,
|
||||
free: 0,
|
||||
jarsPerDelivery: deliveries === null ? 0 : 2,
|
||||
deliveriesCovered: deliveries,
|
||||
});
|
||||
expect(countUndercovered([row(0), row(1), row(null), row(0)])).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { SponsorshipCycle } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Anzeige "naechste Lieferung" in der Abo-Liste. Nur eine Lesehilfe fuer den
|
||||
* Admin — verbindlich entscheidet der taegliche Lieferlauf
|
||||
* (lib/sponsorship-subscription). Die Regeln stammen aus der Spec ("Schutz
|
||||
* vor Verlust: Lieferung erst nach Bezahlung").
|
||||
*/
|
||||
|
||||
/** Vierteljaehrlich: Anspruch nach jeweils drei bezahlten Monaten. */
|
||||
export const QUARTERLY_PAID_MONTHS = 3;
|
||||
/** Ernte: frueheste Lieferung nach sechs bezahlten Monaten seit der letzten. */
|
||||
export const HARVEST_PAID_MONTHS = 6;
|
||||
|
||||
export type NextDeliveryView =
|
||||
| { kind: "quarterly"; remaining: number }
|
||||
| { kind: "harvest"; windowStart: Date | null; paid: number; needed: number }
|
||||
| { kind: "none" };
|
||||
|
||||
/**
|
||||
* @param countingPaidMonths bezahlte, nicht zurueckgebuchte Monate, die schon
|
||||
* fuer eine Lieferung zaehlen (SEPA-Frist abgelaufen) und noch keiner
|
||||
* Lieferung zugeordnet sind.
|
||||
*/
|
||||
export function nextDeliveryView(input: {
|
||||
cycle: SponsorshipCycle | null;
|
||||
countingPaidMonths: number;
|
||||
nextWindowStart: Date | null;
|
||||
}): NextDeliveryView {
|
||||
const paid = Math.max(0, input.countingPaidMonths);
|
||||
if (input.cycle === "VIERTELJAEHRLICH") {
|
||||
return { kind: "quarterly", remaining: Math.max(0, QUARTERLY_PAID_MONTHS - paid) };
|
||||
}
|
||||
if (input.cycle === "ERNTE") {
|
||||
return { kind: "harvest", windowStart: input.nextWindowStart, paid: Math.min(paid, HARVEST_PAID_MONTHS), needed: HARVEST_PAID_MONTHS };
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminBadge, AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { SubscriptionStatus } from "@/generated/prisma/client";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings, type SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
|
||||
import { SubscriptionStatusBadge } from "../_components/status-badge";
|
||||
import { CoveragePanel, SepaSettingsPanel, SepaBlockButton } from "../_components/subscription-panels";
|
||||
import { getHoneyCoverage } from "../_lib/coverage";
|
||||
import { nextDeliveryView, type NextDeliveryView } from "../_lib/subscription-view";
|
||||
import { getSepaHoldDays } from "@/lib/sponsorship/settings";
|
||||
|
||||
const SUBSCRIPTION_STATUSES = ["AUSSTEHEND", "AKTIV", "IM_VERZUG", "GEKUENDIGT", "BEENDET"] as const satisfies readonly SubscriptionStatus[];
|
||||
const LIST_LIMIT = 200;
|
||||
|
||||
function describeNextDelivery(view: NextDeliveryView, ts: SponsorshipAdminStrings["subscriptions"], locale: Locale): string {
|
||||
if (view.kind === "quarterly") return ts.nextQuarterly(view.remaining);
|
||||
if (view.kind === "harvest") {
|
||||
return view.windowStart ? ts.nextHarvest(formatDate(view.windowStart, locale), view.paid, view.needed) : ts.noHarvestWindow;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
export default async function AdminSponsorshipSubscriptionsPage(props: PageProps<"/admin/patenschaften/abos">) {
|
||||
const locale = await getLocale();
|
||||
const t = getSponsorshipAdminStrings(locale);
|
||||
const ts = t.subscriptions;
|
||||
const { status } = await props.searchParams;
|
||||
const statusFilter = (SUBSCRIPTION_STATUSES as readonly string[]).includes(String(status)) ? (status as SubscriptionStatus) : null;
|
||||
const now = new Date();
|
||||
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
|
||||
const [subscriptions, windows, coverage, holdDays, blockedCustomers] = await Promise.all([
|
||||
prisma.sponsorshipSubscription.findMany({
|
||||
where: statusFilter ? { status: statusFilter } : {},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: LIST_LIMIT,
|
||||
include: {
|
||||
customer: { select: { id: true, email: true, firstName: true, lastName: true, sepaBlockedAt: true } },
|
||||
offer: { select: { id: true, name: true, cycle: true } },
|
||||
sponsorship: { select: { id: true, number: true } },
|
||||
payments: { select: { reversedAt: true, deliveryId: true, countsFrom: true } },
|
||||
deliveries: { orderBy: { dueAt: "desc" }, take: 1, select: { dueAt: true } },
|
||||
},
|
||||
}),
|
||||
prisma.harvestWindow.findMany({ where: { endsOn: { gte: today } }, orderBy: { startsOn: "asc" }, select: { offerId: true, startsOn: true } }),
|
||||
getHoneyCoverage(now),
|
||||
getSepaHoldDays(),
|
||||
prisma.customer.findMany({
|
||||
where: { sepaBlockedAt: { not: null } },
|
||||
orderBy: { sepaBlockedAt: "desc" },
|
||||
select: { id: true, email: true, firstName: true, lastName: true, sepaBlockedAt: true },
|
||||
}),
|
||||
]);
|
||||
const nextWindowByOffer = new Map<string, Date>();
|
||||
for (const window of windows) if (!nextWindowByOffer.has(window.offerId)) nextWindowByOffer.set(window.offerId, window.startsOn);
|
||||
|
||||
const selectClass = "border border-ink/20 bg-background px-3 py-2 text-sm";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.common.backToDashboard} />
|
||||
<AdminPageHeader title={ts.title} intro={ts.intro} />
|
||||
<SponsorshipTabs active="subscriptions" labels={t.tabs} />
|
||||
|
||||
<form method="GET" className="mb-6 flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-ink/65">{ts.statusLabel}</span>
|
||||
<select name="status" defaultValue={statusFilter ?? ""} className={selectClass}>
|
||||
<option value="">{ts.statusAll}</option>
|
||||
{SUBSCRIPTION_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t.subscriptionStatuses[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream">
|
||||
{t.common.filter}
|
||||
</button>
|
||||
{statusFilter ? (
|
||||
<Link href="/admin/patenschaften/abos" className="link-draw py-2 text-sm text-ink/65">
|
||||
{t.common.resetFilter}
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto border border-ink/10 bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-ink/65">
|
||||
<tr>
|
||||
<th className="p-4">{ts.colSubscription}</th>
|
||||
<th className="p-4">{ts.colCustomer}</th>
|
||||
<th className="p-4">{ts.colOffer}</th>
|
||||
<th className="p-4">{ts.colStatus}</th>
|
||||
<th className="p-4 text-right">{ts.colPaid}</th>
|
||||
<th className="p-4">{ts.colNextDelivery}</th>
|
||||
<th className="p-4">{ts.colDelinquency}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{subscriptions.map((subscription) => {
|
||||
const paid = subscription.payments.filter((payment) => !payment.reversedAt);
|
||||
const counting = paid.filter((payment) => !payment.deliveryId && payment.countsFrom <= now).length;
|
||||
const next = nextDeliveryView({
|
||||
cycle: subscription.offer.cycle,
|
||||
countingPaidMonths: counting,
|
||||
nextWindowStart: nextWindowByOffer.get(subscription.offer.id) ?? null,
|
||||
});
|
||||
const lastDelivery = subscription.deliveries[0]?.dueAt;
|
||||
return (
|
||||
<tr key={subscription.id} className="align-top transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="whitespace-nowrap p-4 font-mono text-xs">
|
||||
{subscription.sponsorship ? (
|
||||
<Link href={`/admin/patenschaften/${subscription.sponsorship.id}`} className="link-draw text-heading">
|
||||
{subscription.sponsorship.number}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-ink/60">{ts.noSponsorship}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{subscription.customer.firstName} {subscription.customer.lastName}
|
||||
<br />
|
||||
<span className="text-xs text-ink/65">{subscription.customer.email}</span>
|
||||
{subscription.customer.sepaBlockedAt ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<AdminBadge tone="danger">{ts.sepaBlocked}</AdminBadge>
|
||||
<SepaBlockButton customerId={subscription.customer.id} confirmMessage={ts.liftSepaConfirm(subscription.customer.email)} label={ts.liftSepa} />
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{subscription.offer.name}
|
||||
{subscription.offer.cycle ? <span className="block text-xs text-ink/65">{t.cycles[subscription.offer.cycle]}</span> : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<SubscriptionStatusBadge status={subscription.status} label={t.subscriptionStatuses[subscription.status]} />
|
||||
{subscription.cancelAt ? <span className="mt-1 block text-xs text-ink/65">{ts.cancelAt(formatDate(subscription.cancelAt, locale))}</span> : null}
|
||||
</td>
|
||||
<td className="p-4 text-right tabular-nums">{paid.length}</td>
|
||||
<td className="p-4">
|
||||
{subscription.status === "BEENDET" ? "—" : describeNextDelivery(next, ts, locale)}
|
||||
{lastDelivery ? <span className="mt-1 block text-xs text-ink/65">{ts.lastDelivery(formatDate(lastDelivery, locale))}</span> : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-4 text-xs">
|
||||
{subscription.delinquentSince ? (
|
||||
<span className="text-red-700">{t.common.since(formatDate(subscription.delinquentSince, locale))}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{subscriptions.length === 0 ? <AdminEmptyState message={statusFilter ? ts.emptyFiltered : ts.empty} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-5">
|
||||
<div className="lg:col-span-3">
|
||||
<CoveragePanel rows={coverage} t={ts} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 lg:col-span-2">
|
||||
<SepaSettingsPanel holdDays={holdDays} t={ts} />
|
||||
<section className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-2 font-serif text-xl text-heading">{ts.blockedHeading}</h2>
|
||||
<p className="mb-4 text-xs text-ink/65">{ts.blockedIntro}</p>
|
||||
{blockedCustomers.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{ts.noBlocked}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink/10">
|
||||
{blockedCustomers.map((customer) => (
|
||||
<li key={customer.id} className="flex flex-wrap items-center justify-between gap-3 py-3 text-sm">
|
||||
<span>
|
||||
{customer.firstName} {customer.lastName}
|
||||
<span className="block text-xs text-ink/65">{customer.email}</span>
|
||||
{customer.sepaBlockedAt ? (
|
||||
<span className="block text-xs text-ink/65">{ts.blockedSince(formatDate(customer.sepaBlockedAt, locale))}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<SepaBlockButton customerId={customer.id} confirmMessage={ts.liftSepaConfirm(customer.email)} label={ts.liftSepa} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { AdminActionButton, AdminEmptyState } from "@/components/admin-list";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { createHarvestWindowAction, deleteHarvestWindowAction, updateHarvestWindowAction } from "@/app/admin/actions";
|
||||
|
||||
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm";
|
||||
|
||||
function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function WindowFields({ startsOn, endsOn, plannedJars, labels }: {
|
||||
startsOn: string;
|
||||
endsOn: string;
|
||||
plannedJars: number;
|
||||
labels: { startsOn: string; endsOn: string; plannedJars: string };
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{labels.startsOn}</span>
|
||||
<input type="date" name="startsOn" defaultValue={startsOn} required className={inputClass} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{labels.endsOn}</span>
|
||||
<input type="date" name="endsOn" defaultValue={endsOn} required className={inputClass} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{labels.plannedJars}</span>
|
||||
<input type="number" name="plannedJars" min={0} defaultValue={String(plannedJars)} className={inputClass} />
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function AdminHarvestWindowsPage(props: PageProps<"/admin/patenschaften/angebote/[id]/erntefenster">) {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const th = t.harvest;
|
||||
const { id } = await props.params;
|
||||
const offer = await prisma.sponsorshipOffer.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, name: true, kind: true, cycle: true, harvestWindows: { orderBy: { startsOn: "asc" } } },
|
||||
});
|
||||
if (!offer) notFound();
|
||||
const back = <BackLink href={`/admin/patenschaften/angebote/${offer.id}`} label={th.backToOffer} />;
|
||||
|
||||
if (offer.kind !== "ABO" || offer.cycle !== "ERNTE") {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
{back}
|
||||
<h1 className="mb-4 text-2xl text-heading">{th.title(offer.name)}</h1>
|
||||
<AdminEmptyState message={th.notHarvest} bordered />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const today = isoDate(new Date());
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
{back}
|
||||
<h1 className="mb-2 text-2xl text-heading">{th.title(offer.name)}</h1>
|
||||
<p className="mb-8 max-w-2xl text-sm text-ink/70">{th.intro}</p>
|
||||
|
||||
<div className="mb-10 space-y-3">
|
||||
{offer.harvestWindows.length === 0 ? <AdminEmptyState message={th.empty} bordered /> : null}
|
||||
{offer.harvestWindows.map((window) => {
|
||||
const isPast = isoDate(window.endsOn) < today;
|
||||
return (
|
||||
<div key={window.id} className={`flex flex-wrap items-end gap-4 border border-ink/10 bg-background p-4 ${isPast ? "opacity-70" : ""}`}>
|
||||
<form action={updateHarvestWindowAction} className="grid flex-1 grid-cols-1 items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
<input type="hidden" name="id" value={window.id} />
|
||||
<WindowFields startsOn={isoDate(window.startsOn)} endsOn={isoDate(window.endsOn)} plannedJars={window.plannedJars} labels={th} />
|
||||
<button type="submit" className="border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream">
|
||||
{t.common.save}
|
||||
</button>
|
||||
</form>
|
||||
<div className="flex items-center gap-3 pb-2">
|
||||
{isPast ? <span className="text-xs text-ink/60">{th.past}</span> : null}
|
||||
<ConfirmForm action={deleteHarvestWindowAction} confirmMessage={th.deleteConfirm}>
|
||||
<input type="hidden" name="id" value={window.id} />
|
||||
<AdminActionButton tone="danger">{t.common.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-4 font-serif text-xl text-heading">{th.newHeading}</h2>
|
||||
<form action={createHarvestWindowAction} className="grid grid-cols-1 items-end gap-3 sm:grid-cols-[1fr_1fr_1fr_auto]">
|
||||
<input type="hidden" name="offerId" value={offer.id} />
|
||||
<WindowFields startsOn="" endsOn="" plannedJars={0} labels={th} />
|
||||
<button type="submit" className="border border-heading bg-forest px-4 py-2 text-sm text-cream transition-colors hover:bg-honey-deep">
|
||||
{th.add}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminBadge } from "@/components/admin-list";
|
||||
import { asStringArray } from "@/lib/json-array";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { updateSponsorshipOfferAction } from "@/app/admin/actions";
|
||||
import { OfferForm } from "../../_components/offer-form";
|
||||
import { getHoneyProductOptions, getOfferOverview } from "../../_lib/offer-overview";
|
||||
|
||||
export default async function AdminSponsorshipOfferEditPage(props: PageProps<"/admin/patenschaften/angebote/[id]">) {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const tf = t.offerForm;
|
||||
const { id } = await props.params;
|
||||
const [row] = await getOfferOverview(id);
|
||||
if (!row) notFound();
|
||||
const { offer, isAvailable, honeyStock, activeSubscriptions } = row;
|
||||
const honeyOptions = await getHoneyProductOptions(offer.honeyProductId);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/patenschaften/angebote" label={tf.backToOffers} />
|
||||
<h1 className="mb-6 text-2xl text-heading">{tf.editTitle(offer.name)}</h1>
|
||||
|
||||
{/* Live-Verfuegbarkeit: dieselbe Entscheidung wie auf der Verkaufsseite. */}
|
||||
<section className="mb-8 border border-ink/10 bg-background p-6">
|
||||
<h2 className="mb-3 text-xs tracking-wide-caps text-ink/65">{tf.availabilityHeading}</h2>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? t.offers.available : t.offers.unavailable}</AdminBadge>
|
||||
<span className="text-sm text-ink/75">{isAvailable ? tf.availableNow : tf.unavailableNow}</span>
|
||||
</div>
|
||||
{honeyStock ? (
|
||||
<p className="mt-3 text-sm tabular-nums text-ink/75">{tf.stockLine(honeyStock.stock, honeyStock.reserved, honeyStock.free)}</p>
|
||||
) : null}
|
||||
{offer.kind === "ABO" ? (
|
||||
<p className="mt-1 text-sm tabular-nums text-ink/75">{tf.activeSubscriptions(activeSubscriptions, offer.maxActiveSubscriptions)}</p>
|
||||
) : null}
|
||||
{offer.kind === "ABO" && offer.cycle === "ERNTE" ? (
|
||||
<Link href={`/admin/patenschaften/angebote/${offer.id}/erntefenster`} className="link-draw mt-3 inline-block text-sm text-heading">
|
||||
{t.offers.harvestWindows} →
|
||||
</Link>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<OfferForm
|
||||
isNew={false}
|
||||
honeyOptions={honeyOptions}
|
||||
action={updateSponsorshipOfferAction}
|
||||
t={t}
|
||||
defaults={{
|
||||
id: offer.id,
|
||||
kind: offer.kind,
|
||||
name: offer.name,
|
||||
nameEn: offer.nameEn ?? "",
|
||||
slug: offer.slug,
|
||||
description: offer.description,
|
||||
descriptionEn: offer.descriptionEn ?? "",
|
||||
features: asStringArray(offer.features),
|
||||
featuresEn: asStringArray(offer.featuresEn),
|
||||
priceCents: offer.priceCents,
|
||||
honeyShareCentsPerJar: offer.honeyShareCentsPerJar,
|
||||
honeyShareCentsPerMonth: offer.honeyShareCentsPerMonth,
|
||||
isActive: offer.isActive,
|
||||
isPhysical: offer.isPhysical,
|
||||
sortOrder: offer.sortOrder,
|
||||
honeyProductId: offer.honeyProductId,
|
||||
honeyJars: offer.honeyJars,
|
||||
cycle: offer.cycle,
|
||||
maxActiveSubscriptions: offer.maxActiveSubscriptions,
|
||||
medalTier: offer.medalTier,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { createSponsorshipOfferAction } from "@/app/admin/actions";
|
||||
import { EMPTY_OFFER, OfferForm } from "../../_components/offer-form";
|
||||
import { getHoneyProductOptions } from "../../_lib/offer-overview";
|
||||
|
||||
export default async function AdminSponsorshipOfferNewPage() {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const honeyOptions = await getHoneyProductOptions(null);
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/patenschaften/angebote" label={t.offerForm.backToOffers} />
|
||||
<h1 className="mb-8 text-2xl text-heading">{t.offerForm.newTitle}</h1>
|
||||
<OfferForm defaults={EMPTY_OFFER} isNew honeyOptions={honeyOptions} action={createSponsorshipOfferAction} t={t} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import {
|
||||
AdminActionLink,
|
||||
AdminBadge,
|
||||
AdminEmptyState,
|
||||
AdminPageHeader,
|
||||
AdminRowActions,
|
||||
} from "@/components/admin-list";
|
||||
import { formatPrice } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
|
||||
import { getOfferOverview, type OfferOverviewRow } from "../_lib/offer-overview";
|
||||
|
||||
/** Je verknuepftem Honigprodukt eine Zeile (mehrere Angebote koennen dasselbe Produkt nutzen). */
|
||||
function honeyStockRows(rows: OfferOverviewRow[]) {
|
||||
const byProduct = new Map<string, { name: string; isActive: boolean; stock: number; reserved: number; free: number }>();
|
||||
for (const { offer, honeyStock } of rows) {
|
||||
if (!offer.honeyProduct || !honeyStock || byProduct.has(offer.honeyProduct.id)) continue;
|
||||
byProduct.set(offer.honeyProduct.id, { name: offer.honeyProduct.name, isActive: offer.honeyProduct.isActive, ...honeyStock });
|
||||
}
|
||||
return [...byProduct.entries()].sort(([, a], [, b]) => a.name.localeCompare(b.name, "de"));
|
||||
}
|
||||
|
||||
export default async function AdminSponsorshipOffersPage() {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const to = t.offers;
|
||||
const rows = await getOfferOverview();
|
||||
const stockRows = honeyStockRows(rows);
|
||||
const newOffer = { href: "/admin/patenschaften/angebote/neu", label: to.newOffer };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.common.backToDashboard} />
|
||||
<AdminPageHeader title={to.title} intro={to.intro} action={newOffer} />
|
||||
<SponsorshipTabs active="offers" labels={t.tabs} />
|
||||
|
||||
<div className="overflow-x-auto border border-ink/10 bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-ink/65">
|
||||
<tr>
|
||||
<th className="p-4">{to.colName}</th>
|
||||
<th className="p-4">{to.colKind}</th>
|
||||
<th className="p-4">{to.colPrice}</th>
|
||||
<th className="p-4">{to.colHoney}</th>
|
||||
<th className="p-4">{to.colMedal}</th>
|
||||
<th className="p-4">{to.colStatus}</th>
|
||||
<th className="p-4">{to.colAvailability}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{rows.map(({ offer, isAvailable }) => (
|
||||
<tr key={offer.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="p-4">
|
||||
<span className="text-heading">{offer.name}</span>
|
||||
<br />
|
||||
<span className="font-mono text-xs text-ink/60">{offer.slug}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{t.kinds[offer.kind]}
|
||||
{offer.cycle ? <span className="block text-xs text-ink/65">{t.cycles[offer.cycle]}</span> : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-4 tabular-nums">
|
||||
{formatPrice(offer.priceCents)}
|
||||
{offer.kind === "ABO" ? <span className="text-ink/65"> {to.perMonth}</span> : null}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{offer.honeyProduct && offer.honeyJars > 0 ? to.jars(offer.honeyJars, offer.honeyProduct.name) : <span className="text-ink/60">{to.noHoney}</span>}
|
||||
</td>
|
||||
<td className="p-4">{t.medals[offer.medalTier]}</td>
|
||||
<td className="p-4">
|
||||
<AdminBadge tone={offer.isActive ? "positive" : "neutral"}>{offer.isActive ? to.active : to.inactive}</AdminBadge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<AdminBadge tone={isAvailable ? "positive" : "pending"}>{isAvailable ? to.available : to.unavailable}</AdminBadge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<AdminRowActions>
|
||||
{offer.kind === "ABO" && offer.cycle === "ERNTE" ? (
|
||||
<AdminActionLink href={`/admin/patenschaften/angebote/${offer.id}/erntefenster`}>{to.harvestWindows}</AdminActionLink>
|
||||
) : null}
|
||||
<AdminActionLink href={`/admin/patenschaften/angebote/${offer.id}`}>{to.edit}</AdminActionLink>
|
||||
</AdminRowActions>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 ? <AdminEmptyState message={to.empty} action={newOffer} /> : null}
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-3 text-lg text-heading">{to.stockHeading}</h2>
|
||||
{stockRows.length === 0 ? (
|
||||
<p className="text-sm text-ink/65">{to.noLinkedHoney}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto border border-ink/10 bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-ink/65">
|
||||
<tr>
|
||||
<th className="p-4">{to.colProduct}</th>
|
||||
<th className="p-4 text-right">{to.colStock}</th>
|
||||
<th className="p-4 text-right">{to.colReserved}</th>
|
||||
<th className="p-4 text-right">{to.colFree}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{stockRows.map(([productId, row]) => (
|
||||
<tr key={productId}>
|
||||
<td className="p-4">
|
||||
{row.name}
|
||||
{row.isActive ? null : <span className="ml-2 text-xs text-ink/60">({to.inactive})</span>}
|
||||
</td>
|
||||
<td className="p-4 text-right tabular-nums">{row.stock}</td>
|
||||
<td className="p-4 text-right tabular-nums">{row.reserved}</td>
|
||||
<td className="p-4 text-right tabular-nums text-heading">{row.free}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { AdminBadge, AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { SponsorshipTabs } from "./_components/sponsorship-tabs";
|
||||
import { SponsorshipStatusBadge } from "./_components/status-badge";
|
||||
import { Pagination } from "./_components/pagination";
|
||||
import { beeNameFromJson } from "./_lib/revision-data";
|
||||
import { LIST_STATUSES, PAGE_SIZE, buildSponsorshipWhere, listHref, parseListFilters } from "./_lib/sponsorship-filters";
|
||||
|
||||
const selectClass = "border border-ink/20 bg-background px-3 py-2 text-sm";
|
||||
|
||||
export default async function AdminSponsorshipsPage(props: PageProps<"/admin/patenschaften">) {
|
||||
const locale = await getLocale();
|
||||
const t = getSponsorshipAdminStrings(locale);
|
||||
const filters = parseListFilters(await props.searchParams);
|
||||
const where = await buildSponsorshipWhere(filters);
|
||||
|
||||
const [total, offers] = await Promise.all([
|
||||
prisma.sponsorship.count({ where }),
|
||||
prisma.sponsorshipOffer.findMany({ select: { id: true, name: true }, orderBy: [{ kind: "asc" }, { sortOrder: "asc" }] }),
|
||||
]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const page = Math.min(filters.page, totalPages);
|
||||
const sponsorships = await prisma.sponsorship.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
take: PAGE_SIZE,
|
||||
include: {
|
||||
owner: { select: { email: true, firstName: true, lastName: true } },
|
||||
offer: { select: { name: true, kind: true, isPhysical: true } },
|
||||
revisions: { orderBy: { revision: "desc" }, take: 1, select: { personalization: true } },
|
||||
orderItem: { select: { personalization: true } },
|
||||
subscription: { select: { personalization: true } },
|
||||
},
|
||||
});
|
||||
const isFiltered = Boolean(filters.query || filters.status || filters.kind || filters.offerId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.common.backToDashboard} />
|
||||
<AdminPageHeader title={t.list.title} intro={t.list.intro} />
|
||||
<SponsorshipTabs active="overview" labels={t.tabs} />
|
||||
|
||||
<AdminSearchForm
|
||||
query={filters.query}
|
||||
label={t.list.searchLabel}
|
||||
placeholder={t.list.searchPlaceholder}
|
||||
submitLabel={t.common.filter}
|
||||
resetHref="/admin/patenschaften"
|
||||
resetLabel={t.common.resetFilter}
|
||||
isFiltered={isFiltered}
|
||||
>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-ink/65">{t.list.statusLabel}</span>
|
||||
<select name="status" defaultValue={filters.status ?? ""} className={selectClass}>
|
||||
<option value="">{t.list.statusAll}</option>
|
||||
{LIST_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status === "ZU_DRUCKEN" ? t.list.toPrint : t.statuses[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-ink/65">{t.list.kindLabel}</span>
|
||||
<select name="art" defaultValue={filters.kind ?? ""} className={selectClass}>
|
||||
<option value="">{t.list.kindAll}</option>
|
||||
<option value="EINMALIG">{t.kinds.EINMALIG}</option>
|
||||
<option value="ABO">{t.kinds.ABO}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block text-xs text-ink/65">{t.list.offerLabel}</span>
|
||||
<select name="angebot" defaultValue={filters.offerId ?? ""} className={selectClass}>
|
||||
<option value="">{t.list.offerAll}</option>
|
||||
{offers.map((offer) => (
|
||||
<option key={offer.id} value={offer.id}>
|
||||
{offer.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</AdminSearchForm>
|
||||
|
||||
<p className="mb-3 text-xs text-ink/60">{t.list.resultCount(total)}</p>
|
||||
<div className="overflow-x-auto border border-ink/10 bg-background">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-ink/10 text-left text-ink/65">
|
||||
<tr>
|
||||
<th className="p-4">{t.list.colNumber}</th>
|
||||
<th className="p-4">{t.list.colBee}</th>
|
||||
<th className="p-4">{t.list.colOwner}</th>
|
||||
<th className="p-4">{t.list.colOffer}</th>
|
||||
<th className="p-4">{t.list.colTerm}</th>
|
||||
<th className="p-4">{t.list.colStatus}</th>
|
||||
<th className="p-4">{t.list.colCreated}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink/10">
|
||||
{sponsorships.map((sponsorship) => {
|
||||
const beeName =
|
||||
beeNameFromJson(sponsorship.revisions[0]?.personalization) ??
|
||||
beeNameFromJson(sponsorship.orderItem?.personalization ?? sponsorship.subscription?.personalization);
|
||||
const toPrint = sponsorship.offer.isPhysical && sponsorship.status === "BEREIT" && !sponsorship.printedAt;
|
||||
const start = formatDate(sponsorship.startDate, locale);
|
||||
return (
|
||||
<tr key={sponsorship.id} className="transition-colors hover:bg-ink/[0.03]">
|
||||
<td className="whitespace-nowrap p-4 font-mono text-xs text-heading">
|
||||
<Link href={`/admin/patenschaften/${sponsorship.id}`} className="link-draw">
|
||||
{sponsorship.number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-4 font-serif text-base text-heading">{beeName ?? t.common.none}</td>
|
||||
<td className="p-4">
|
||||
{sponsorship.owner.firstName} {sponsorship.owner.lastName}
|
||||
<br />
|
||||
<span className="text-xs text-ink/65">{sponsorship.owner.email}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{sponsorship.offer.name}
|
||||
<br />
|
||||
<span className="text-xs text-ink/65">{t.kinds[sponsorship.offer.kind]}</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-4 tabular-nums">
|
||||
{sponsorship.endDate ? t.common.range(start, formatDate(sponsorship.endDate, locale)) : t.common.since(start)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<SponsorshipStatusBadge status={sponsorship.status} label={t.statuses[sponsorship.status]} />
|
||||
{toPrint ? (
|
||||
<AdminBadge tone="pending" pulse>
|
||||
{t.list.toPrintBadge}
|
||||
</AdminBadge>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap p-4 tabular-nums text-ink/70">{formatDate(sponsorship.createdAt, locale)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{sponsorships.length === 0 ? <AdminEmptyState message={isFiltered ? t.list.emptyFiltered : t.list.empty} /> : null}
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(target) => listHref(filters, target)} labels={t.common} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { ConfirmForm } from "@/components/confirm-form";
|
||||
import { AdminActionButton, AdminActionNote, AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
|
||||
import type { LoyaltyKind } from "@/generated/prisma/client";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getSponsorshipAdminStrings, type SponsorshipAdminStrings } from "@/i18n/sponsorship/admin";
|
||||
import { createLoyaltyLevelAction, deleteLoyaltyLevelAction, updateLoyaltyLevelAction } from "@/app/admin/actions";
|
||||
import { SponsorshipTabs } from "../_components/sponsorship-tabs";
|
||||
|
||||
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm";
|
||||
const saveClass = "border border-heading px-4 py-2 text-sm text-heading transition-colors hover:bg-forest hover:text-cream";
|
||||
|
||||
interface Level {
|
||||
id: string;
|
||||
kind: LoyaltyKind;
|
||||
threshold: number | null;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
}
|
||||
|
||||
function LevelRow({ level, t, common }: { level: Level; t: SponsorshipAdminStrings["loyalty"]; common: SponsorshipAdminStrings["common"] }) {
|
||||
const isVip = level.threshold === null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4 py-3">
|
||||
<form action={updateLoyaltyLevelAction} className="grid flex-1 grid-cols-1 items-end gap-3 sm:grid-cols-[7rem_1fr_1fr_auto]">
|
||||
<input type="hidden" name="id" value={level.id} />
|
||||
{isVip ? (
|
||||
<div className="text-sm">
|
||||
<span className="text-xs text-ink/65">{t.threshold}</span>
|
||||
<p className="mt-1 py-2 text-xs text-honey-dark">{t.vip}</p>
|
||||
</div>
|
||||
) : (
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.threshold}</span>
|
||||
<input type="number" name="threshold" min={1} defaultValue={String(level.threshold)} required className={`${inputClass} tabular-nums`} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.name}</span>
|
||||
<input name="name" defaultValue={level.name} required className={inputClass} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.nameEn}</span>
|
||||
<input name="nameEn" defaultValue={level.nameEn ?? ""} className={inputClass} />
|
||||
</label>
|
||||
<button type="submit" className={saveClass}>
|
||||
{common.save}
|
||||
</button>
|
||||
</form>
|
||||
<div className="w-28 pb-2">
|
||||
{isVip ? (
|
||||
<AdminActionNote>{t.vipNotDeletable}</AdminActionNote>
|
||||
) : (
|
||||
<ConfirmForm action={deleteLoyaltyLevelAction} confirmMessage={t.deleteConfirm(level.name)}>
|
||||
<input type="hidden" name="id" value={level.id} />
|
||||
<AdminActionButton tone="danger">{common.delete}</AdminActionButton>
|
||||
</ConfirmForm>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelSection({ kind, heading, hint, levels, t, common }: {
|
||||
kind: LoyaltyKind;
|
||||
heading: string;
|
||||
hint: string;
|
||||
levels: Level[];
|
||||
t: SponsorshipAdminStrings["loyalty"];
|
||||
common: SponsorshipAdminStrings["common"];
|
||||
}) {
|
||||
return (
|
||||
<section className="border border-ink/10 bg-background p-6">
|
||||
<h2 className="font-serif text-xl text-heading">{heading}</h2>
|
||||
<p className="mb-4 mt-1 text-xs text-ink/65">{hint}</p>
|
||||
{levels.length === 0 ? <AdminEmptyState message={t.empty} /> : null}
|
||||
<div className="divide-y divide-ink/10">
|
||||
{levels.map((level) => (
|
||||
<LevelRow key={level.id} level={level} t={t} common={common} />
|
||||
))}
|
||||
</div>
|
||||
<form action={createLoyaltyLevelAction} className="mt-6 grid grid-cols-1 items-end gap-3 border-t border-ink/10 pt-6 sm:grid-cols-[7rem_1fr_1fr_auto]">
|
||||
<input type="hidden" name="kind" value={kind} />
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.threshold}</span>
|
||||
<input type="number" name="threshold" min={1} required className={`${inputClass} tabular-nums`} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.name}</span>
|
||||
<input name="name" required className={inputClass} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-xs text-ink/65">{t.nameEn}</span>
|
||||
<input name="nameEn" className={inputClass} />
|
||||
</label>
|
||||
<button type="submit" className="border border-heading bg-forest px-4 py-2 text-sm text-cream transition-colors hover:bg-honey-deep">
|
||||
{t.add}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schwellen aufsteigend, VIP (ohne Schwelle) immer zuletzt. */
|
||||
function byThreshold(a: Level, b: Level): number {
|
||||
return (a.threshold ?? Number.POSITIVE_INFINITY) - (b.threshold ?? Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
export default async function AdminLoyaltyLevelsPage() {
|
||||
const t = getSponsorshipAdminStrings(await getLocale());
|
||||
const tl = t.loyalty;
|
||||
const levels = await prisma.loyaltyLevel.findMany({ select: { id: true, kind: true, threshold: true, name: true, nameEn: true } });
|
||||
const regular = levels.filter((level) => level.kind === "STAMMKUNDE").sort(byThreshold);
|
||||
const subscription = levels.filter((level) => level.kind === "ABO").sort(byThreshold);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<BackLink href="/admin" label={t.common.backToDashboard} />
|
||||
<AdminPageHeader title={tl.title} intro={tl.intro} />
|
||||
<SponsorshipTabs active="loyalty" labels={t.tabs} />
|
||||
<div className="space-y-8">
|
||||
<LevelSection kind="STAMMKUNDE" heading={tl.regularHeading} hint={tl.regularHint} levels={regular} t={tl} common={t.common} />
|
||||
<LevelSection kind="ABO" heading={tl.aboHeading} hint={tl.aboHint} levels={subscription} t={tl} common={t.common} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
||||
|
||||
const [products, activeCount, archivedCount] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where: { archivedAt: isArchive ? { not: null } : null },
|
||||
// Traegerprodukte der Patenschaft pflegt der Admin unter "Patenschaften".
|
||||
where: { archivedAt: isArchive ? { not: null } : null, category: { not: "PATENSCHAFT" } },
|
||||
include: {
|
||||
harvest: true,
|
||||
batch: true,
|
||||
@@ -37,8 +38,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.product.count({ where: { archivedAt: null } }),
|
||||
prisma.product.count({ where: { archivedAt: { not: null } } }),
|
||||
prisma.product.count({ where: { archivedAt: null, category: { not: "PATENSCHAFT" } } }),
|
||||
prisma.product.count({ where: { archivedAt: { not: null }, category: { not: "PATENSCHAFT" } } }),
|
||||
]);
|
||||
|
||||
const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct };
|
||||
|
||||
Reference in new issue
Block a user