Files
Wabenhain/src/app/admin/patenschaften/[id]/page.tsx
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 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]>
2026-10-16 14:07:30 +02:00

188 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}