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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 14:07:30 +02:00
1 parent dcb578f062
commit f227aeb8f9
269 files changed
+28054 -141

No files matched your search

+5
View File
@@ -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.");
}
+191
View File
@@ -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.`);
}
+201
View File
@@ -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);
}
+6 -1
View File
@@ -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>
);
}
+187
View File
@@ -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" };
}
+197
View File
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+4 -3
View File
@@ -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 };