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]>
188 lines
8.2 KiB
TypeScript
188 lines
8.2 KiB
TypeScript
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>
|
||
);
|
||
}
|