Files
Wabenhain/src/app/admin/bestellungen/[id]/page.tsx
T
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse
Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).

Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.

ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:19:42 +02:00

333 lines
15 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 { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { formatPrice, formatDate } from "@/lib/format";
import {
updateOrderStatusAction,
updateShipmentTrackingAction,
createDhlLabelAction,
deleteOrderAction,
} from "../../actions";
import { ConfirmForm } from "@/components/confirm-form";
import { OrderStatusForm } from "@/components/order-status-form";
import { checkOrderDeletable } from "@/lib/order-delete";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { Button } from "@/components/ui";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale());
const statusLabels = t.orderStatusBadge;
const { id } = await props.params;
const order = await prisma.order.findUnique({
where: { id },
include: {
items: { include: { product: { select: { slug: true } }, batch: { select: { code: true } } } },
payment: true,
shipment: true,
pickup: true,
invoice: { include: { cancellation: { select: { number: true } } } },
customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } },
},
});
if (!order) notFound();
const dhlConfigured = isDhlConfigured();
// Dieselbe Regel, die deleteOrderAction serverseitig durchsetzt — geteilt
// ueber lib/order-delete.ts, damit Anzeige und Durchsetzung nicht
// auseinanderlaufen koennen.
const isDeletable = checkOrderDeletable({
status: order.status,
hasPayment: order.payment !== null,
hasInvoice: order.invoice !== null,
}).deletable;
return (
<div className="max-w-4xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl text-heading">{order.orderNumber}</h1>
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
</div>
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
</div>
<OrderStatusForm
action={updateOrderStatusAction}
orderId={order.id}
currentStatus={order.status}
labels={statusLabels}
saveLabel={t.adminOrderDetailPage.saveStatus}
statusAriaLabel={t.adminOrdersPage.statusLabel}
reopenConfirmMessage={t.adminOrdersPage.reopenConfirm}
/>
</div>
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
<div className="mb-8 border border-ink/10 bg-background px-6 py-5">
<OrderStatusTimeline
status={order.status}
fulfillmentMethod={order.fulfillmentMethod}
labels={{
ordered: t.orderStatusTimeline.ordered,
paid: t.orderStatusTimeline.paid,
preparing: t.orderStatusTimeline.preparing,
readyToShip: t.orderStatusTimeline.readyToShip,
shipped: t.orderStatusTimeline.shipped,
readyForPickup: t.orderStatusTimeline.readyForPickup,
pickedUp: t.orderStatusTimeline.pickedUp,
cancelled: t.orderStatusTimeline.cancelled,
ariaLabel: t.orderStatusTimeline.ariaLabel,
}}
/>
</div>
{order.giftMessage ? (
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
<p className="whitespace-pre-wrap text-ink">{order.giftMessage}</p>
</div>
) : null}
<div className="grid sm:grid-cols-2 gap-6 mb-8">
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.customerHeading}</h2>
<p>{order.firstName} {order.lastName}</p>
<p className="text-ink/70">{order.email}</p>
{order.phone ? <p className="text-ink/70">{order.phone}</p> : null}
{order.customer ? (
<p className="mt-2 text-xs text-honey-dark">{t.adminOrderDetailPage.registeredAccount}</p>
) : (
<p className="mt-2 text-xs text-ink/65">{t.adminOrderDetailPage.guestOrder}</p>
)}
</div>
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">
{order.fulfillmentMethod === "VERSAND"
? t.adminOrderDetailPage.shippingAddressHeading
: t.adminOrderDetailPage.pickupHeading}
</h2>
{order.fulfillmentMethod === "VERSAND" ? (
<>
<p>{order.street}</p>
<p>{order.zip} {order.city}</p>
{/* Nur bei Packstation-Lieferung: ohne die Postnummer laesst
sich keine Versandmarke erstellen, und ohne Anzeige hier
waere nicht nachvollziehbar, warum DHL sie ablehnt. */}
{order.packstationNumber ? (
<p className="mt-1 text-ink/70">
{t.adminOrderDetailPage.postNumberLabel}: {order.postNumber ?? "—"}
</p>
) : null}
<p className="mt-2 text-xs text-ink/65">
{order.shipment?.shippedAt
? t.adminOrderDetailPage.shippedOn(formatDate(order.shipment.shippedAt))
: t.adminOrderDetailPage.notShippedYet}
</p>
</>
) : (
<p className="text-xs text-ink/65">
{order.pickup?.pickedUpAt
? t.adminOrderDetailPage.pickedUpOn(formatDate(order.pickup.pickedUpAt))
: order.pickup?.readyAt
? t.adminOrderDetailPage.readySince(formatDate(order.pickup.readyAt))
: t.adminOrderDetailPage.notPickedUpYet}
</p>
)}
</div>
</div>
{order.fulfillmentMethod === "VERSAND" && order.payment?.status === "SUCCEEDED" ? (
<div className="bg-background border border-ink/10 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.dhlHeading}</h2>
{order.shipment?.trackingNumber ? (
<p className="text-sm text-ink/70">
{t.adminOrderDetailPage.dhlAlreadyCreated(order.shipment.trackingNumber)}
</p>
) : dhlConfigured ? (
<form action={createDhlLabelAction}>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" className="bg-honey-deep text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.dhlCreateButton}
</button>
</form>
) : (
<p className="text-sm text-ink/65">{t.adminOrderDetailPage.dhlNotConfigured}</p>
)}
</div>
) : null}
{order.fulfillmentMethod === "VERSAND" ? (
<div className="bg-background border border-ink/10 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
<form action={updateShipmentTrackingAction} className="flex flex-wrap items-end gap-4">
<input type="hidden" name="orderId" value={order.id} />
<label className="block text-sm">
<span className="text-ink/70">{t.adminOrderDetailPage.carrierLabel}</span>
<input
name="carrier"
defaultValue={order.shipment?.carrier ?? ""}
placeholder={t.adminOrderDetailPage.carrierPlaceholder}
className="mt-1 w-48 border border-ink/20 bg-background px-3 py-2 text-sm"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminOrderDetailPage.trackingNumberLabel}</span>
<input
name="trackingNumber"
defaultValue={order.shipment?.trackingNumber ?? ""}
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
/>
</label>
<Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveTracking}
</Button>
</form>
</div>
) : null}
<div className="bg-background border border-ink/10 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.itemsHeading}</h2>
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="pb-2">{t.adminOrderDetailPage.colProduct}</th>
<th className="pb-2">{t.adminOrderDetailPage.colBatch}</th>
<th className="pb-2 text-right">{t.adminOrderDetailPage.colQuantity}</th>
<th className="pb-2 text-right">{t.adminOrderDetailPage.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{order.items.map((item) => (
<tr key={item.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="py-2">
<Link href={`/admin/produkte/${item.productId}`} className="text-heading underline">
{item.productName}
</Link>{" "}
<span className="text-ink/65">({item.weightGrams} g)</span>
</td>
<td className="py-2 text-ink/70">{item.batch?.code ?? "—"}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right">{formatPrice(item.totalCents, order.currency)}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex justify-end">
<div className="w-56 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-ink/70">{t.adminOrderDetailPage.subtotalLabel}</span>
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.adminOrderDetailPage.shippingLabel}</span>
<span>
{order.shippingCents === 0
? t.adminOrderDetailPage.freeShipping
: formatPrice(order.shippingCents, order.currency)}
</span>
</div>
{order.discountCents > 0 ? (
<div className="flex justify-between">
<span className="text-ink/70">
{t.adminOrderDetailPage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
</span>
<span>−{formatPrice(order.discountCents, order.currency)}</span>
</div>
) : null}
<div className="flex justify-between text-heading pt-1 border-t border-ink/10">
<span>{t.adminOrderDetailPage.totalLabel}</span>
<span>{formatPrice(order.totalCents, order.currency)}</span>
</div>
</div>
</div>
</div>
<div className="grid sm:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.paymentHeading}</h2>
{order.payment ? (
<>
<p>{order.payment.status}</p>
<p className="text-ink/70">{formatPrice(order.payment.amountCents, order.payment.currency)}</p>
{order.payment.paidAt ? (
<p className="text-xs text-ink/65 mt-1">
{t.adminOrderDetailPage.paidOn(formatDate(order.payment.paidAt))}
</p>
) : null}
</>
) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noPaymentYet}</p>
)}
</div>
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-3">{t.adminOrderDetailPage.invoiceHeading}</h2>
{order.invoice ? (
<div className="flex flex-col gap-1">
<span>{order.invoice.invoiceNumber}</span>
<a
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
className="link-draw text-sm text-heading"
>
{t.adminOrderDetailPage.downloadPdf}
</a>
{order.invoice.cancellation && (
<>
<span className="mt-3">
{t.invoicePage.cancellationTitle} {order.invoice.cancellation.number}
</span>
<a
href={`/rechnung/${order.invoice.cancellation.number}/pdf`}
className="link-draw text-sm text-heading"
>
{t.adminOrderDetailPage.downloadPdf}
</a>
</>
)}
</div>
) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
)}
</div>
</div>
{/* Dieselben drei Bedingungen prueft deleteOrderAction noch einmal
serverseitig. Das hier ist reine Anzeige: ein Knopf, den man nicht
sieht, ist keine Sicherheitsgrenze (siehe requireAdmin in
actions/_shared.ts). Steht am Seitenende, damit man nicht aus
Versehen daraufklickt, waehrend man oben den Status setzt. */}
<div className="mt-10 border border-red-200 bg-red-50/40 p-6">
<h2 className="text-xs tracking-wide-caps text-red-700 mb-3">
{t.adminOrderDetailPage.deleteHeading}
</h2>
{isDeletable ? (
<>
<p className="text-sm text-ink/70 leading-relaxed mb-4">
{t.adminOrderDetailPage.deleteHint}
</p>
<ConfirmForm
action={deleteOrderAction}
confirmMessage={t.adminOrderDetailPage.deleteConfirm(order.orderNumber)}
>
<input type="hidden" name="orderId" value={order.id} />
<button type="submit" className="bg-red-700 text-cream px-4 py-2 text-sm">
{t.adminOrderDetailPage.deleteButton}
</button>
</ConfirmForm>
</>
) : (
<p className="text-sm text-ink/70 leading-relaxed">{t.adminOrderDetailPage.deleteBlocked}</p>
)}
</div>
</div>
);
}